thailint 0.5.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 (204) 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 +38 -25
  23. src/core/base.py +7 -2
  24. src/core/cli_utils.py +19 -2
  25. src/core/config_parser.py +5 -2
  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 +120 -20
  65. src/linters/dry/block_grouper.py +4 -0
  66. src/linters/dry/cache.py +104 -10
  67. src/linters/dry/cache_query.py +4 -0
  68. src/linters/dry/config.py +54 -11
  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 +5 -4
  73. src/linters/dry/file_analyzer.py +4 -2
  74. src/linters/dry/inline_ignore.py +7 -16
  75. src/linters/dry/linter.py +183 -48
  76. src/linters/dry/python_analyzer.py +60 -439
  77. src/linters/dry/python_constant_extractor.py +100 -0
  78. src/linters/dry/single_statement_detector.py +417 -0
  79. src/linters/dry/token_hasher.py +116 -112
  80. src/linters/dry/typescript_analyzer.py +68 -382
  81. src/linters/dry/typescript_constant_extractor.py +138 -0
  82. src/linters/dry/typescript_statement_detector.py +255 -0
  83. src/linters/dry/typescript_value_extractor.py +70 -0
  84. src/linters/dry/violation_builder.py +4 -0
  85. src/linters/dry/violation_filter.py +5 -4
  86. src/linters/dry/violation_generator.py +71 -14
  87. src/linters/file_header/atemporal_detector.py +68 -50
  88. src/linters/file_header/base_parser.py +93 -0
  89. src/linters/file_header/bash_parser.py +66 -0
  90. src/linters/file_header/config.py +90 -16
  91. src/linters/file_header/css_parser.py +70 -0
  92. src/linters/file_header/field_validator.py +36 -33
  93. src/linters/file_header/linter.py +140 -144
  94. src/linters/file_header/markdown_parser.py +130 -0
  95. src/linters/file_header/python_parser.py +14 -58
  96. src/linters/file_header/typescript_parser.py +73 -0
  97. src/linters/file_header/violation_builder.py +13 -12
  98. src/linters/file_placement/config_loader.py +3 -1
  99. src/linters/file_placement/directory_matcher.py +4 -0
  100. src/linters/file_placement/linter.py +66 -34
  101. src/linters/file_placement/pattern_matcher.py +41 -6
  102. src/linters/file_placement/pattern_validator.py +31 -12
  103. src/linters/file_placement/rule_checker.py +12 -7
  104. src/linters/lazy_ignores/__init__.py +43 -0
  105. src/linters/lazy_ignores/config.py +74 -0
  106. src/linters/lazy_ignores/directive_utils.py +164 -0
  107. src/linters/lazy_ignores/header_parser.py +177 -0
  108. src/linters/lazy_ignores/linter.py +158 -0
  109. src/linters/lazy_ignores/matcher.py +168 -0
  110. src/linters/lazy_ignores/python_analyzer.py +209 -0
  111. src/linters/lazy_ignores/rule_id_utils.py +180 -0
  112. src/linters/lazy_ignores/skip_detector.py +298 -0
  113. src/linters/lazy_ignores/types.py +71 -0
  114. src/linters/lazy_ignores/typescript_analyzer.py +146 -0
  115. src/linters/lazy_ignores/violation_builder.py +135 -0
  116. src/linters/lbyl/__init__.py +31 -0
  117. src/linters/lbyl/config.py +63 -0
  118. src/linters/lbyl/linter.py +67 -0
  119. src/linters/lbyl/pattern_detectors/__init__.py +53 -0
  120. src/linters/lbyl/pattern_detectors/base.py +63 -0
  121. src/linters/lbyl/pattern_detectors/dict_key_detector.py +107 -0
  122. src/linters/lbyl/pattern_detectors/division_check_detector.py +232 -0
  123. src/linters/lbyl/pattern_detectors/file_exists_detector.py +220 -0
  124. src/linters/lbyl/pattern_detectors/hasattr_detector.py +119 -0
  125. src/linters/lbyl/pattern_detectors/isinstance_detector.py +119 -0
  126. src/linters/lbyl/pattern_detectors/len_check_detector.py +173 -0
  127. src/linters/lbyl/pattern_detectors/none_check_detector.py +146 -0
  128. src/linters/lbyl/pattern_detectors/string_validator_detector.py +145 -0
  129. src/linters/lbyl/python_analyzer.py +215 -0
  130. src/linters/lbyl/violation_builder.py +354 -0
  131. src/linters/magic_numbers/context_analyzer.py +227 -225
  132. src/linters/magic_numbers/linter.py +28 -82
  133. src/linters/magic_numbers/python_analyzer.py +4 -16
  134. src/linters/magic_numbers/typescript_analyzer.py +9 -12
  135. src/linters/magic_numbers/typescript_ignore_checker.py +81 -0
  136. src/linters/method_property/__init__.py +49 -0
  137. src/linters/method_property/config.py +138 -0
  138. src/linters/method_property/linter.py +414 -0
  139. src/linters/method_property/python_analyzer.py +473 -0
  140. src/linters/method_property/violation_builder.py +119 -0
  141. src/linters/nesting/linter.py +24 -16
  142. src/linters/nesting/python_analyzer.py +4 -0
  143. src/linters/nesting/typescript_analyzer.py +6 -12
  144. src/linters/nesting/violation_builder.py +1 -0
  145. src/linters/performance/__init__.py +91 -0
  146. src/linters/performance/config.py +43 -0
  147. src/linters/performance/constants.py +49 -0
  148. src/linters/performance/linter.py +149 -0
  149. src/linters/performance/python_analyzer.py +365 -0
  150. src/linters/performance/regex_analyzer.py +312 -0
  151. src/linters/performance/regex_linter.py +139 -0
  152. src/linters/performance/typescript_analyzer.py +236 -0
  153. src/linters/performance/violation_builder.py +160 -0
  154. src/linters/print_statements/config.py +7 -12
  155. src/linters/print_statements/linter.py +26 -43
  156. src/linters/print_statements/python_analyzer.py +91 -93
  157. src/linters/print_statements/typescript_analyzer.py +15 -25
  158. src/linters/print_statements/violation_builder.py +12 -14
  159. src/linters/srp/class_analyzer.py +11 -7
  160. src/linters/srp/heuristics.py +56 -22
  161. src/linters/srp/linter.py +15 -16
  162. src/linters/srp/python_analyzer.py +55 -20
  163. src/linters/srp/typescript_metrics_calculator.py +110 -50
  164. src/linters/stateless_class/__init__.py +25 -0
  165. src/linters/stateless_class/config.py +58 -0
  166. src/linters/stateless_class/linter.py +349 -0
  167. src/linters/stateless_class/python_analyzer.py +290 -0
  168. src/linters/stringly_typed/__init__.py +36 -0
  169. src/linters/stringly_typed/config.py +189 -0
  170. src/linters/stringly_typed/context_filter.py +451 -0
  171. src/linters/stringly_typed/function_call_violation_builder.py +135 -0
  172. src/linters/stringly_typed/ignore_checker.py +100 -0
  173. src/linters/stringly_typed/ignore_utils.py +51 -0
  174. src/linters/stringly_typed/linter.py +376 -0
  175. src/linters/stringly_typed/python/__init__.py +33 -0
  176. src/linters/stringly_typed/python/analyzer.py +348 -0
  177. src/linters/stringly_typed/python/call_tracker.py +175 -0
  178. src/linters/stringly_typed/python/comparison_tracker.py +257 -0
  179. src/linters/stringly_typed/python/condition_extractor.py +134 -0
  180. src/linters/stringly_typed/python/conditional_detector.py +179 -0
  181. src/linters/stringly_typed/python/constants.py +21 -0
  182. src/linters/stringly_typed/python/match_analyzer.py +94 -0
  183. src/linters/stringly_typed/python/validation_detector.py +189 -0
  184. src/linters/stringly_typed/python/variable_extractor.py +96 -0
  185. src/linters/stringly_typed/storage.py +620 -0
  186. src/linters/stringly_typed/storage_initializer.py +45 -0
  187. src/linters/stringly_typed/typescript/__init__.py +28 -0
  188. src/linters/stringly_typed/typescript/analyzer.py +157 -0
  189. src/linters/stringly_typed/typescript/call_tracker.py +335 -0
  190. src/linters/stringly_typed/typescript/comparison_tracker.py +378 -0
  191. src/linters/stringly_typed/violation_generator.py +419 -0
  192. src/orchestrator/core.py +252 -14
  193. src/orchestrator/language_detector.py +5 -3
  194. src/templates/thailint_config_template.yaml +196 -0
  195. src/utils/project_root.py +3 -0
  196. thailint-0.15.3.dist-info/METADATA +187 -0
  197. thailint-0.15.3.dist-info/RECORD +226 -0
  198. thailint-0.15.3.dist-info/entry_points.txt +4 -0
  199. src/cli.py +0 -1665
  200. thailint-0.5.0.dist-info/METADATA +0 -1286
  201. thailint-0.5.0.dist-info/RECORD +0 -96
  202. thailint-0.5.0.dist-info/entry_points.txt +0 -4
  203. {thailint-0.5.0.dist-info → thailint-0.15.3.dist-info}/WHEEL +0 -0
  204. {thailint-0.5.0.dist-info → thailint-0.15.3.dist-info}/licenses/LICENSE +0 -0
@@ -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
+ )
@@ -0,0 +1,290 @@
1
+ """
2
+ Purpose: Python AST analyzer for detecting stateless classes
3
+
4
+ Scope: AST-based analysis of Python class definitions for stateless patterns
5
+
6
+ Overview: Analyzes Python source code using AST to detect classes that have no
7
+ constructor (__init__ or __new__), no instance state (self.attr assignments),
8
+ and 2+ methods - indicating they should be refactored to module-level functions.
9
+ Excludes legitimate patterns like ABC, Protocol, decorated classes, and classes
10
+ with class-level attributes.
11
+
12
+ Dependencies: Python AST module
13
+
14
+ Exports: analyze_code function, ClassInfo dataclass
15
+
16
+ Interfaces: analyze_code(code) -> list[ClassInfo] returning detected stateless classes
17
+
18
+ Implementation: AST visitor pattern with focused helper functions for different checks
19
+ """
20
+
21
+ import ast
22
+ from dataclasses import dataclass
23
+
24
+
25
+ @dataclass
26
+ class ClassInfo:
27
+ """Information about a detected stateless class."""
28
+
29
+ name: str
30
+ line: int
31
+ column: int
32
+
33
+
34
+ def analyze_code(code: str, min_methods: int = 2) -> list[ClassInfo]:
35
+ """Analyze Python code for stateless classes.
36
+
37
+ Args:
38
+ code: Python source code
39
+ min_methods: Minimum methods required to flag class
40
+
41
+ Returns:
42
+ List of detected stateless class info
43
+ """
44
+ try:
45
+ tree = ast.parse(code)
46
+ except SyntaxError:
47
+ return []
48
+
49
+ return _find_stateless_classes(tree, min_methods)
50
+
51
+
52
+ def _find_stateless_classes(tree: ast.Module, min_methods: int = 2) -> list[ClassInfo]:
53
+ """Find all stateless classes in AST.
54
+
55
+ Args:
56
+ tree: Parsed AST module
57
+ min_methods: Minimum methods required to flag class
58
+
59
+ Returns:
60
+ List of stateless class info
61
+ """
62
+ results = []
63
+ for node in ast.walk(tree):
64
+ if isinstance(node, ast.ClassDef) and _is_stateless(node, min_methods):
65
+ results.append(ClassInfo(node.name, node.lineno, node.col_offset))
66
+ return results
67
+
68
+
69
+ def _is_stateless(class_node: ast.ClassDef, min_methods: int = 2) -> bool:
70
+ """Check if class is stateless and should be functions.
71
+
72
+ Args:
73
+ class_node: AST ClassDef node
74
+ min_methods: Minimum methods required to flag class
75
+
76
+ Returns:
77
+ True if class is stateless violation
78
+ """
79
+ if _should_skip_class(class_node):
80
+ return False
81
+ return _count_methods(class_node) >= min_methods
82
+
83
+
84
+ def _should_skip_class(class_node: ast.ClassDef) -> bool:
85
+ """Check if class should be skipped from analysis.
86
+
87
+ Args:
88
+ class_node: AST ClassDef node
89
+
90
+ Returns:
91
+ True if class should be skipped
92
+ """
93
+ return (
94
+ _has_constructor(class_node)
95
+ or _is_exception_case(class_node)
96
+ or _has_class_attributes(class_node)
97
+ or _has_instance_attributes(class_node)
98
+ or _has_base_classes(class_node)
99
+ )
100
+
101
+
102
+ def _has_base_classes(class_node: ast.ClassDef) -> bool:
103
+ """Check if class inherits from non-trivial base classes.
104
+
105
+ Classes that inherit from other classes are using polymorphism/inheritance
106
+ and should not be flagged as stateless.
107
+
108
+ Args:
109
+ class_node: AST ClassDef node
110
+
111
+ Returns:
112
+ True if class has non-trivial base classes
113
+ """
114
+ if not class_node.bases:
115
+ return False
116
+
117
+ for base in class_node.bases:
118
+ base_name = _get_base_name(base)
119
+ # Skip trivial bases like object
120
+ if base_name and base_name not in ("object",):
121
+ return True
122
+
123
+ return False
124
+
125
+
126
+ def _count_methods(class_node: ast.ClassDef) -> int:
127
+ """Count methods in class.
128
+
129
+ Args:
130
+ class_node: AST ClassDef node
131
+
132
+ Returns:
133
+ Number of methods
134
+ """
135
+ return sum(1 for item in class_node.body if isinstance(item, ast.FunctionDef))
136
+
137
+
138
+ def _has_constructor(class_node: ast.ClassDef) -> bool:
139
+ """Check if class has __init__ or __new__ method.
140
+
141
+ Args:
142
+ class_node: AST ClassDef node
143
+
144
+ Returns:
145
+ True if class has constructor
146
+ """
147
+ constructor_names = ("__init__", "__new__")
148
+ return any(
149
+ isinstance(item, ast.FunctionDef) and item.name in constructor_names
150
+ for item in class_node.body
151
+ )
152
+
153
+
154
+ def _is_exception_case(class_node: ast.ClassDef) -> bool:
155
+ """Check if class is an exception case (ABC, Protocol, or decorated).
156
+
157
+ Args:
158
+ class_node: AST ClassDef node
159
+
160
+ Returns:
161
+ True if class is ABC, Protocol, or decorated
162
+ """
163
+ if class_node.decorator_list:
164
+ return True
165
+ return _inherits_from_abc_or_protocol(class_node)
166
+
167
+
168
+ def _inherits_from_abc_or_protocol(class_node: ast.ClassDef) -> bool:
169
+ """Check if class inherits from ABC or Protocol.
170
+
171
+ Args:
172
+ class_node: AST ClassDef node
173
+
174
+ Returns:
175
+ True if inherits from ABC or Protocol
176
+ """
177
+ return any(_get_base_name(base) in ("ABC", "Protocol") for base in class_node.bases)
178
+
179
+
180
+ def _get_base_name(base: ast.expr) -> str:
181
+ """Extract name from base class expression.
182
+
183
+ Args:
184
+ base: AST expression for base class
185
+
186
+ Returns:
187
+ Base class name or empty string
188
+ """
189
+ if isinstance(base, ast.Name):
190
+ return base.id
191
+ if isinstance(base, ast.Attribute):
192
+ return base.attr
193
+ return ""
194
+
195
+
196
+ def _has_class_attributes(class_node: ast.ClassDef) -> bool:
197
+ """Check if class has class-level attributes.
198
+
199
+ Args:
200
+ class_node: AST ClassDef node
201
+
202
+ Returns:
203
+ True if class has class attributes
204
+ """
205
+ return any(isinstance(item, (ast.Assign, ast.AnnAssign)) for item in class_node.body)
206
+
207
+
208
+ def _has_instance_attributes(class_node: ast.ClassDef) -> bool:
209
+ """Check if methods assign to self.attr.
210
+
211
+ Args:
212
+ class_node: AST ClassDef node
213
+
214
+ Returns:
215
+ True if any method assigns to self
216
+ """
217
+ return any(
218
+ isinstance(item, ast.FunctionDef) and _method_has_self_assignment(item)
219
+ for item in class_node.body
220
+ )
221
+
222
+
223
+ def _method_has_self_assignment(method: ast.FunctionDef) -> bool:
224
+ """Check if method assigns to self.attr.
225
+
226
+ Args:
227
+ method: AST FunctionDef node
228
+
229
+ Returns:
230
+ True if method assigns to self
231
+ """
232
+ return any(_is_self_attribute_assignment(node) for node in ast.walk(method))
233
+
234
+
235
+ def _is_self_attribute_assignment(node: ast.AST) -> bool:
236
+ """Check if node is a self.attr assignment.
237
+
238
+ Args:
239
+ node: AST node to check
240
+
241
+ Returns:
242
+ True if node is self attribute assignment
243
+ """
244
+ if not isinstance(node, ast.Assign):
245
+ return False
246
+ return any(_is_self_attribute(t) for t in node.targets)
247
+
248
+
249
+ def _is_self_attribute(node: ast.expr) -> bool:
250
+ """Check if node is a self.attr reference.
251
+
252
+ Args:
253
+ node: AST expression node
254
+
255
+ Returns:
256
+ True if node is self.attr
257
+ """
258
+ if not isinstance(node, ast.Attribute):
259
+ return False
260
+ if not isinstance(node.value, ast.Name):
261
+ return False
262
+ return node.value.id == "self"
263
+
264
+
265
+ # Legacy class wrapper for backward compatibility with linter.py
266
+ class StatelessClassAnalyzer:
267
+ """Analyzes Python code for stateless classes.
268
+
269
+ Note: This class is a thin wrapper around module-level functions
270
+ to maintain backward compatibility with existing code.
271
+ """
272
+
273
+ def __init__(self, min_methods: int = 2) -> None:
274
+ """Initialize the analyzer.
275
+
276
+ Args:
277
+ min_methods: Minimum methods required to flag class
278
+ """
279
+ self._min_methods = min_methods
280
+
281
+ def analyze(self, code: str) -> list[ClassInfo]:
282
+ """Analyze Python code for stateless classes.
283
+
284
+ Args:
285
+ code: Python source code
286
+
287
+ Returns:
288
+ List of detected stateless class info
289
+ """
290
+ return analyze_code(code, self._min_methods)
@@ -0,0 +1,36 @@
1
+ """
2
+ Purpose: Stringly-typed linter package exports
3
+
4
+ Scope: Public API for stringly-typed linter module
5
+
6
+ Overview: Provides the public interface for the stringly-typed linter package. Exports
7
+ StringlyTypedConfig for configuration and StringlyTypedRule for linting. The stringly-typed
8
+ linter detects code patterns where plain strings are used instead of proper enums or typed
9
+ alternatives, helping identify potential type safety improvements. Supports cross-file
10
+ detection to find repeated string patterns across the codebase. Includes IgnoreChecker
11
+ for inline ignore directive support.
12
+
13
+ Dependencies: .config for StringlyTypedConfig, .linter for StringlyTypedRule,
14
+ .storage for StringlyTypedStorage, .ignore_checker for IgnoreChecker
15
+
16
+ Exports: StringlyTypedConfig, StringlyTypedRule, StringlyTypedStorage, StoredPattern,
17
+ IgnoreChecker
18
+
19
+ Interfaces: Configuration loading via StringlyTypedConfig.from_dict(),
20
+ StringlyTypedRule.check() and finalize() for linting, IgnoreChecker.filter_violations()
21
+
22
+ Implementation: Module-level exports with __all__ definition
23
+ """
24
+
25
+ from src.linters.stringly_typed.config import StringlyTypedConfig
26
+ from src.linters.stringly_typed.ignore_checker import IgnoreChecker
27
+ from src.linters.stringly_typed.linter import StringlyTypedRule
28
+ from src.linters.stringly_typed.storage import StoredPattern, StringlyTypedStorage
29
+
30
+ __all__ = [
31
+ "StringlyTypedConfig",
32
+ "IgnoreChecker",
33
+ "StringlyTypedRule",
34
+ "StringlyTypedStorage",
35
+ "StoredPattern",
36
+ ]