thailint 0.8.0__py3-none-any.whl → 0.10.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. src/cli.py +242 -0
  2. src/config.py +2 -3
  3. src/core/base.py +4 -0
  4. src/core/rule_discovery.py +143 -84
  5. src/core/violation_builder.py +75 -15
  6. src/linter_config/loader.py +43 -11
  7. src/linters/collection_pipeline/__init__.py +90 -0
  8. src/linters/collection_pipeline/config.py +63 -0
  9. src/linters/collection_pipeline/continue_analyzer.py +100 -0
  10. src/linters/collection_pipeline/detector.py +130 -0
  11. src/linters/collection_pipeline/linter.py +437 -0
  12. src/linters/collection_pipeline/suggestion_builder.py +63 -0
  13. src/linters/dry/block_filter.py +6 -8
  14. src/linters/dry/block_grouper.py +4 -0
  15. src/linters/dry/cache_query.py +4 -0
  16. src/linters/dry/python_analyzer.py +34 -18
  17. src/linters/dry/token_hasher.py +5 -1
  18. src/linters/dry/typescript_analyzer.py +61 -31
  19. src/linters/dry/violation_builder.py +4 -0
  20. src/linters/dry/violation_filter.py +4 -0
  21. src/linters/file_header/bash_parser.py +4 -0
  22. src/linters/file_header/linter.py +7 -11
  23. src/linters/file_placement/directory_matcher.py +4 -0
  24. src/linters/file_placement/linter.py +28 -8
  25. src/linters/file_placement/pattern_matcher.py +4 -0
  26. src/linters/file_placement/pattern_validator.py +4 -0
  27. src/linters/magic_numbers/context_analyzer.py +4 -0
  28. src/linters/magic_numbers/typescript_analyzer.py +4 -0
  29. src/linters/nesting/python_analyzer.py +4 -0
  30. src/linters/nesting/typescript_function_extractor.py +4 -0
  31. src/linters/print_statements/typescript_analyzer.py +4 -0
  32. src/linters/srp/class_analyzer.py +4 -0
  33. src/linters/srp/heuristics.py +4 -3
  34. src/linters/srp/linter.py +2 -3
  35. src/linters/srp/python_analyzer.py +55 -20
  36. src/linters/srp/typescript_metrics_calculator.py +83 -47
  37. src/linters/srp/violation_builder.py +4 -0
  38. src/linters/stateless_class/__init__.py +25 -0
  39. src/linters/stateless_class/config.py +58 -0
  40. src/linters/stateless_class/linter.py +355 -0
  41. src/linters/stateless_class/python_analyzer.py +299 -0
  42. {thailint-0.8.0.dist-info → thailint-0.10.0.dist-info}/METADATA +226 -3
  43. {thailint-0.8.0.dist-info → thailint-0.10.0.dist-info}/RECORD +46 -36
  44. {thailint-0.8.0.dist-info → thailint-0.10.0.dist-info}/WHEEL +0 -0
  45. {thailint-0.8.0.dist-info → thailint-0.10.0.dist-info}/entry_points.txt +0 -0
  46. {thailint-0.8.0.dist-info → thailint-0.10.0.dist-info}/licenses/LICENSE +0 -0
@@ -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,355 @@
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
+
25
+ from pathlib import Path
26
+
27
+ from src.core.base import BaseLintContext, BaseLintRule
28
+ from src.core.types import Severity, Violation
29
+ from src.linter_config.ignore import IgnoreDirectiveParser
30
+
31
+ from .config import StatelessClassConfig
32
+ from .python_analyzer import ClassInfo, StatelessClassAnalyzer
33
+
34
+
35
+ class StatelessClassRule(BaseLintRule): # thailint: ignore[srp,dry]
36
+ """Detects stateless classes that should be module-level functions."""
37
+
38
+ def __init__(self) -> None:
39
+ """Initialize the rule with analyzer and ignore parser."""
40
+ self._ignore_parser = IgnoreDirectiveParser()
41
+
42
+ @property
43
+ def rule_id(self) -> str:
44
+ """Unique identifier for this rule."""
45
+ return "stateless-class.violation"
46
+
47
+ @property
48
+ def rule_name(self) -> str:
49
+ """Human-readable name for this rule."""
50
+ return "Stateless Class Detection"
51
+
52
+ @property
53
+ def description(self) -> str:
54
+ """Description of what this rule checks."""
55
+ return "Classes without state should be refactored to module-level functions"
56
+
57
+ def check(self, context: BaseLintContext) -> list[Violation]:
58
+ """Check for stateless class violations.
59
+
60
+ Args:
61
+ context: Lint context with file information
62
+
63
+ Returns:
64
+ List of violations found
65
+ """
66
+ if not self._should_analyze(context):
67
+ return []
68
+
69
+ config = self._load_config(context)
70
+ if not config.enabled:
71
+ return []
72
+
73
+ if self._is_file_ignored(context, config):
74
+ return []
75
+
76
+ if self._has_file_level_ignore(context):
77
+ return []
78
+
79
+ analyzer = StatelessClassAnalyzer(min_methods=config.min_methods)
80
+ stateless_classes = analyzer.analyze(context.file_content) # type: ignore[arg-type]
81
+
82
+ return self._filter_ignored_violations(stateless_classes, context)
83
+
84
+ def _should_analyze(self, context: BaseLintContext) -> bool:
85
+ """Check if context should be analyzed.
86
+
87
+ Args:
88
+ context: Lint context
89
+
90
+ Returns:
91
+ True if should analyze
92
+ """
93
+ return context.language == "python" and context.file_content is not None
94
+
95
+ def _load_config(self, context: BaseLintContext) -> StatelessClassConfig:
96
+ """Load configuration from context.
97
+
98
+ Args:
99
+ context: Lint context
100
+
101
+ Returns:
102
+ StatelessClassConfig instance
103
+ """
104
+ if not hasattr(context, "config") or context.config is None:
105
+ return StatelessClassConfig()
106
+
107
+ config_dict = context.config
108
+ if not isinstance(config_dict, dict):
109
+ return StatelessClassConfig()
110
+
111
+ # Check for stateless-class specific config
112
+ linter_config = config_dict.get("stateless-class", config_dict)
113
+ return StatelessClassConfig.from_dict(linter_config)
114
+
115
+ def _is_file_ignored(self, context: BaseLintContext, config: StatelessClassConfig) -> bool:
116
+ """Check if file matches ignore patterns.
117
+
118
+ Args:
119
+ context: Lint context
120
+ config: Configuration
121
+
122
+ Returns:
123
+ True if file should be ignored
124
+ """
125
+ if not config.ignore:
126
+ return False
127
+
128
+ if not context.file_path:
129
+ return False
130
+
131
+ file_path = Path(context.file_path)
132
+ for pattern in config.ignore:
133
+ if self._matches_pattern(file_path, pattern):
134
+ return True
135
+ return False
136
+
137
+ def _matches_pattern(self, file_path: Path, pattern: str) -> bool:
138
+ """Check if file path matches a glob pattern.
139
+
140
+ Args:
141
+ file_path: Path to check
142
+ pattern: Glob pattern
143
+
144
+ Returns:
145
+ True if path matches pattern
146
+ """
147
+ if file_path.match(pattern):
148
+ return True
149
+ if pattern in str(file_path):
150
+ return True
151
+ return False
152
+
153
+ def _has_file_level_ignore(self, context: BaseLintContext) -> bool:
154
+ """Check if file has file-level ignore directive.
155
+
156
+ Args:
157
+ context: Lint context
158
+
159
+ Returns:
160
+ True if file should be ignored at file level
161
+ """
162
+ if not context.file_content:
163
+ return False
164
+
165
+ # Check first 10 lines for ignore-file directive
166
+ lines = context.file_content.splitlines()[:10]
167
+ for line in lines:
168
+ if self._is_file_ignore_directive(line):
169
+ return True
170
+ return False
171
+
172
+ def _is_file_ignore_directive(self, line: str) -> bool:
173
+ """Check if line is a file-level ignore directive.
174
+
175
+ Args:
176
+ line: Line to check
177
+
178
+ Returns:
179
+ True if line has file-level ignore for this rule
180
+ """
181
+ line_lower = line.lower()
182
+ if "thailint: ignore-file" not in line_lower:
183
+ return False
184
+
185
+ # Check for general ignore-file (no rule specified)
186
+ if "ignore-file[" not in line_lower:
187
+ return True
188
+
189
+ # Check for rule-specific ignore
190
+ return self._matches_rule_ignore(line_lower, "ignore-file")
191
+
192
+ def _matches_rule_ignore(self, line: str, directive: str) -> bool:
193
+ """Check if line matches rule-specific ignore.
194
+
195
+ Args:
196
+ line: Line to check (lowercase)
197
+ directive: Directive name (ignore-file or ignore)
198
+
199
+ Returns:
200
+ True if ignore applies to this rule
201
+ """
202
+ import re
203
+
204
+ pattern = rf"{directive}\[([^\]]+)\]"
205
+ match = re.search(pattern, line)
206
+ if not match:
207
+ return False
208
+
209
+ rules = [r.strip().lower() for r in match.group(1).split(",")]
210
+ return any(self._rule_matches(r) for r in rules)
211
+
212
+ def _rule_matches(self, rule_pattern: str) -> bool:
213
+ """Check if rule pattern matches this rule.
214
+
215
+ Args:
216
+ rule_pattern: Rule pattern to check
217
+
218
+ Returns:
219
+ True if pattern matches this rule
220
+ """
221
+ rule_id_lower = self.rule_id.lower()
222
+ pattern_lower = rule_pattern.lower()
223
+
224
+ # Exact match
225
+ if rule_id_lower == pattern_lower:
226
+ return True
227
+
228
+ # Prefix match: stateless-class matches stateless-class.violation
229
+ if rule_id_lower.startswith(pattern_lower + "."):
230
+ return True
231
+
232
+ # Wildcard match: stateless-class.* matches stateless-class.violation
233
+ if pattern_lower.endswith("*"):
234
+ prefix = pattern_lower[:-1]
235
+ return rule_id_lower.startswith(prefix)
236
+
237
+ return False
238
+
239
+ def _filter_ignored_violations(
240
+ self, classes: list[ClassInfo], context: BaseLintContext
241
+ ) -> list[Violation]:
242
+ """Filter out violations that should be ignored.
243
+
244
+ Args:
245
+ classes: List of stateless classes found
246
+ context: Lint context
247
+
248
+ Returns:
249
+ List of violations after filtering ignored ones
250
+ """
251
+ violations = []
252
+ for info in classes:
253
+ violation = self._create_violation(info, context)
254
+ if not self._should_ignore_violation(violation, info, context):
255
+ violations.append(violation)
256
+ return violations
257
+
258
+ def _should_ignore_violation(
259
+ self, violation: Violation, info: ClassInfo, context: BaseLintContext
260
+ ) -> bool:
261
+ """Check if violation should be ignored.
262
+
263
+ Args:
264
+ violation: Violation to check
265
+ info: Class info
266
+ context: Lint context
267
+
268
+ Returns:
269
+ True if violation should be ignored
270
+ """
271
+ if not context.file_content:
272
+ return False
273
+
274
+ # Check using IgnoreDirectiveParser for comprehensive ignore checking
275
+ if self._ignore_parser.should_ignore_violation(violation, context.file_content):
276
+ return True
277
+
278
+ # Also check inline ignore on class line
279
+ return self._has_inline_ignore(info.line, context)
280
+
281
+ def _has_inline_ignore(self, line_num: int, context: BaseLintContext) -> bool:
282
+ """Check for inline ignore directive on class line.
283
+
284
+ Args:
285
+ line_num: Line number to check
286
+ context: Lint context
287
+
288
+ Returns:
289
+ True if line has ignore directive
290
+ """
291
+ line = self._get_line_text(line_num, context)
292
+ if not line:
293
+ return False
294
+
295
+ return self._is_ignore_directive(line.lower())
296
+
297
+ def _get_line_text(self, line_num: int, context: BaseLintContext) -> str | None:
298
+ """Get text of a specific line.
299
+
300
+ Args:
301
+ line_num: Line number (1-indexed)
302
+ context: Lint context
303
+
304
+ Returns:
305
+ Line text or None if invalid
306
+ """
307
+ if not context.file_content:
308
+ return None
309
+
310
+ lines = context.file_content.splitlines()
311
+ if line_num <= 0 or line_num > len(lines):
312
+ return None
313
+
314
+ return lines[line_num - 1]
315
+
316
+ def _is_ignore_directive(self, line: str) -> bool:
317
+ """Check if line contains ignore directive for this rule.
318
+
319
+ Args:
320
+ line: Line text (lowercase)
321
+
322
+ Returns:
323
+ True if line has applicable ignore directive
324
+ """
325
+ if "thailint:" not in line or "ignore" not in line:
326
+ return False
327
+
328
+ # General ignore (no rule specified)
329
+ if "ignore[" not in line:
330
+ return True
331
+
332
+ # Rule-specific ignore
333
+ return self._matches_rule_ignore(line, "ignore")
334
+
335
+ def _create_violation(self, info: ClassInfo, context: BaseLintContext) -> Violation:
336
+ """Create violation from class info.
337
+
338
+ Args:
339
+ info: Detected stateless class info
340
+ context: Lint context
341
+
342
+ Returns:
343
+ Violation instance
344
+ """
345
+ message = (
346
+ f"Class '{info.name}' has no state and should be refactored to module-level functions"
347
+ )
348
+ return Violation(
349
+ rule_id=self.rule_id,
350
+ message=message,
351
+ file_path=str(context.file_path),
352
+ line=info.line,
353
+ column=info.column,
354
+ severity=Severity.ERROR,
355
+ )