thailint 0.10.0__py3-none-any.whl → 0.12.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 (76) hide show
  1. src/__init__.py +1 -0
  2. src/cli/__init__.py +27 -0
  3. src/cli/__main__.py +22 -0
  4. src/cli/config.py +478 -0
  5. src/cli/linters/__init__.py +58 -0
  6. src/cli/linters/code_patterns.py +372 -0
  7. src/cli/linters/code_smells.py +450 -0
  8. src/cli/linters/documentation.py +155 -0
  9. src/cli/linters/shared.py +89 -0
  10. src/cli/linters/structure.py +313 -0
  11. src/cli/linters/structure_quality.py +316 -0
  12. src/cli/main.py +120 -0
  13. src/cli/utils.py +395 -0
  14. src/cli_main.py +34 -0
  15. src/core/types.py +13 -0
  16. src/core/violation_utils.py +69 -0
  17. src/linter_config/ignore.py +32 -16
  18. src/linters/collection_pipeline/linter.py +2 -2
  19. src/linters/dry/block_filter.py +97 -1
  20. src/linters/dry/cache.py +94 -6
  21. src/linters/dry/config.py +47 -10
  22. src/linters/dry/constant.py +92 -0
  23. src/linters/dry/constant_matcher.py +214 -0
  24. src/linters/dry/constant_violation_builder.py +98 -0
  25. src/linters/dry/linter.py +89 -48
  26. src/linters/dry/python_analyzer.py +12 -415
  27. src/linters/dry/python_constant_extractor.py +101 -0
  28. src/linters/dry/single_statement_detector.py +415 -0
  29. src/linters/dry/token_hasher.py +5 -5
  30. src/linters/dry/typescript_analyzer.py +5 -354
  31. src/linters/dry/typescript_constant_extractor.py +134 -0
  32. src/linters/dry/typescript_statement_detector.py +255 -0
  33. src/linters/dry/typescript_value_extractor.py +66 -0
  34. src/linters/file_header/linter.py +2 -2
  35. src/linters/file_placement/linter.py +2 -2
  36. src/linters/file_placement/pattern_matcher.py +19 -5
  37. src/linters/magic_numbers/linter.py +8 -67
  38. src/linters/magic_numbers/typescript_ignore_checker.py +81 -0
  39. src/linters/nesting/linter.py +12 -9
  40. src/linters/print_statements/linter.py +7 -24
  41. src/linters/srp/class_analyzer.py +9 -9
  42. src/linters/srp/heuristics.py +2 -2
  43. src/linters/srp/linter.py +2 -2
  44. src/linters/stateless_class/linter.py +2 -2
  45. src/linters/stringly_typed/__init__.py +36 -0
  46. src/linters/stringly_typed/config.py +190 -0
  47. src/linters/stringly_typed/context_filter.py +451 -0
  48. src/linters/stringly_typed/function_call_violation_builder.py +137 -0
  49. src/linters/stringly_typed/ignore_checker.py +102 -0
  50. src/linters/stringly_typed/ignore_utils.py +51 -0
  51. src/linters/stringly_typed/linter.py +344 -0
  52. src/linters/stringly_typed/python/__init__.py +33 -0
  53. src/linters/stringly_typed/python/analyzer.py +344 -0
  54. src/linters/stringly_typed/python/call_tracker.py +172 -0
  55. src/linters/stringly_typed/python/comparison_tracker.py +252 -0
  56. src/linters/stringly_typed/python/condition_extractor.py +131 -0
  57. src/linters/stringly_typed/python/conditional_detector.py +176 -0
  58. src/linters/stringly_typed/python/constants.py +21 -0
  59. src/linters/stringly_typed/python/match_analyzer.py +88 -0
  60. src/linters/stringly_typed/python/validation_detector.py +186 -0
  61. src/linters/stringly_typed/python/variable_extractor.py +96 -0
  62. src/linters/stringly_typed/storage.py +630 -0
  63. src/linters/stringly_typed/storage_initializer.py +45 -0
  64. src/linters/stringly_typed/typescript/__init__.py +28 -0
  65. src/linters/stringly_typed/typescript/analyzer.py +157 -0
  66. src/linters/stringly_typed/typescript/call_tracker.py +329 -0
  67. src/linters/stringly_typed/typescript/comparison_tracker.py +372 -0
  68. src/linters/stringly_typed/violation_generator.py +376 -0
  69. src/orchestrator/core.py +241 -12
  70. {thailint-0.10.0.dist-info → thailint-0.12.0.dist-info}/METADATA +9 -3
  71. {thailint-0.10.0.dist-info → thailint-0.12.0.dist-info}/RECORD +74 -28
  72. thailint-0.12.0.dist-info/entry_points.txt +4 -0
  73. src/cli.py +0 -2141
  74. thailint-0.10.0.dist-info/entry_points.txt +0 -4
  75. {thailint-0.10.0.dist-info → thailint-0.12.0.dist-info}/WHEEL +0 -0
  76. {thailint-0.10.0.dist-info → thailint-0.12.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,344 @@
1
+ """
2
+ Purpose: Coordinate Python stringly-typed pattern detection
3
+
4
+ Scope: Orchestrate detection of all stringly-typed patterns in Python files
5
+
6
+ Overview: Provides PythonStringlyTypedAnalyzer class that coordinates detection of
7
+ stringly-typed patterns across Python source files. Uses MembershipValidationDetector
8
+ to find 'x in ("a", "b")' patterns, ConditionalPatternDetector to find if/elif chains
9
+ and match statements, and FunctionCallTracker to find function calls with string
10
+ arguments. Returns unified AnalysisResult objects for validation patterns and
11
+ FunctionCallResult objects for function calls. Handles AST parsing errors gracefully
12
+ and provides a single entry point for Python analysis. Supports configuration options
13
+ for filtering and thresholds.
14
+
15
+ Dependencies: ast module, MembershipValidationDetector, ConditionalPatternDetector,
16
+ FunctionCallTracker, StringlyTypedConfig
17
+
18
+ Exports: PythonStringlyTypedAnalyzer class, AnalysisResult dataclass, FunctionCallResult dataclass,
19
+ ComparisonResult dataclass
20
+
21
+ Interfaces: PythonStringlyTypedAnalyzer.analyze(code, file_path) -> list[AnalysisResult],
22
+ PythonStringlyTypedAnalyzer.analyze_function_calls(code, file_path) -> list[FunctionCallResult]
23
+
24
+ Implementation: Facade pattern coordinating multiple detectors with unified result format
25
+ """
26
+
27
+ import ast
28
+ from dataclasses import dataclass
29
+ from pathlib import Path
30
+
31
+ from ..config import StringlyTypedConfig
32
+ from .call_tracker import FunctionCallPattern, FunctionCallTracker
33
+ from .comparison_tracker import ComparisonPattern, ComparisonTracker
34
+ from .conditional_detector import ConditionalPatternDetector, EqualityChainPattern
35
+ from .validation_detector import MembershipPattern, MembershipValidationDetector
36
+
37
+
38
+ @dataclass
39
+ class AnalysisResult:
40
+ """Represents a stringly-typed pattern detected in Python code.
41
+
42
+ Provides a unified representation of detected patterns from all detectors,
43
+ including pattern type, string values, location, and contextual information.
44
+ """
45
+
46
+ pattern_type: str
47
+ """Type of pattern detected: 'membership_validation', 'equality_chain', etc."""
48
+
49
+ string_values: set[str]
50
+ """Set of string values used in the pattern."""
51
+
52
+ file_path: Path
53
+ """Path to the file containing the pattern."""
54
+
55
+ line_number: int
56
+ """Line number where the pattern occurs (1-indexed)."""
57
+
58
+ column: int
59
+ """Column number where the pattern starts (0-indexed)."""
60
+
61
+ variable_name: str | None
62
+ """Variable name involved in the pattern, if identifiable."""
63
+
64
+ details: str
65
+ """Human-readable description of the detected pattern."""
66
+
67
+
68
+ @dataclass
69
+ class FunctionCallResult:
70
+ """Represents a function call with a string argument.
71
+
72
+ Provides information about a single function call with a string literal
73
+ argument, enabling aggregation across files to detect limited value sets.
74
+ """
75
+
76
+ function_name: str
77
+ """Fully qualified function name (e.g., 'process' or 'obj.method')."""
78
+
79
+ param_index: int
80
+ """Index of the parameter receiving the string value (0-indexed)."""
81
+
82
+ string_value: str
83
+ """The string literal value passed to the function."""
84
+
85
+ file_path: Path
86
+ """Path to the file containing the call."""
87
+
88
+ line_number: int
89
+ """Line number where the call occurs (1-indexed)."""
90
+
91
+ column: int
92
+ """Column number where the call starts (0-indexed)."""
93
+
94
+
95
+ @dataclass
96
+ class ComparisonResult:
97
+ """Represents a string comparison found in Python code.
98
+
99
+ Provides information about a comparison like `if env == "production"` to
100
+ enable cross-file aggregation for detecting scattered comparisons that
101
+ suggest missing enums.
102
+ """
103
+
104
+ variable_name: str
105
+ """Variable name being compared (e.g., 'env' or 'self.status')."""
106
+
107
+ compared_value: str
108
+ """The string literal value being compared to."""
109
+
110
+ operator: str
111
+ """The comparison operator ('==' or '!=')."""
112
+
113
+ file_path: Path
114
+ """Path to the file containing the comparison."""
115
+
116
+ line_number: int
117
+ """Line number where the comparison occurs (1-indexed)."""
118
+
119
+ column: int
120
+ """Column number where the comparison starts (0-indexed)."""
121
+
122
+
123
+ class PythonStringlyTypedAnalyzer: # thailint: ignore srp
124
+ """Analyzes Python code for stringly-typed patterns.
125
+
126
+ Coordinates detection of various stringly-typed patterns including membership
127
+ validation ('x in ("a", "b")'), equality chains ('if x == "a" elif x == "b"'),
128
+ and function calls with string arguments ('process("active")').
129
+ Provides configuration-aware analysis with filtering support.
130
+
131
+ Note: Method count exceeds SRP limit due to analyzer coordination role. Multiple
132
+ analysis methods are required for different pattern types (membership, conditional,
133
+ function calls, comparisons) and their converters.
134
+ """
135
+
136
+ def __init__(self, config: StringlyTypedConfig | None = None) -> None:
137
+ """Initialize the analyzer with optional configuration.
138
+
139
+ Args:
140
+ config: Configuration for stringly-typed detection. Uses defaults if None.
141
+ """
142
+ self.config = config or StringlyTypedConfig()
143
+ self._membership_detector = MembershipValidationDetector()
144
+ self._conditional_detector = ConditionalPatternDetector()
145
+ self._call_tracker = FunctionCallTracker()
146
+ self._comparison_tracker = ComparisonTracker()
147
+
148
+ def analyze(self, code: str, file_path: Path) -> list[AnalysisResult]:
149
+ """Analyze Python code for stringly-typed patterns.
150
+
151
+ Args:
152
+ code: Python source code to analyze
153
+ file_path: Path to the file being analyzed
154
+
155
+ Returns:
156
+ List of AnalysisResult instances for each detected pattern
157
+ """
158
+ tree = self._parse_code(code)
159
+ if tree is None:
160
+ return []
161
+
162
+ results: list[AnalysisResult] = []
163
+
164
+ # Detect membership validation patterns
165
+ membership_patterns = self._membership_detector.find_patterns(tree)
166
+ results.extend(
167
+ self._convert_membership_pattern(pattern, file_path) for pattern in membership_patterns
168
+ )
169
+
170
+ # Detect equality chain patterns
171
+ conditional_patterns = self._conditional_detector.find_patterns(tree)
172
+ results.extend(
173
+ self._convert_conditional_pattern(pattern, file_path)
174
+ for pattern in conditional_patterns
175
+ )
176
+
177
+ return results
178
+
179
+ def _parse_code(self, code: str) -> ast.AST | None:
180
+ """Parse Python source code into an AST.
181
+
182
+ Args:
183
+ code: Python source code to parse
184
+
185
+ Returns:
186
+ AST if parsing succeeds, None if parsing fails
187
+ """
188
+ try:
189
+ return ast.parse(code)
190
+ except SyntaxError:
191
+ return None
192
+
193
+ def _convert_membership_pattern(
194
+ self, pattern: MembershipPattern, file_path: Path
195
+ ) -> AnalysisResult:
196
+ """Convert a MembershipPattern to unified AnalysisResult.
197
+
198
+ Args:
199
+ pattern: Detected membership pattern
200
+ file_path: Path to the file containing the pattern
201
+
202
+ Returns:
203
+ AnalysisResult representing the pattern
204
+ """
205
+ values_str = ", ".join(sorted(pattern.string_values))
206
+ var_info = f" on '{pattern.variable_name}'" if pattern.variable_name else ""
207
+ details = (
208
+ f"Membership validation{var_info} with {len(pattern.string_values)} "
209
+ f"string values ({pattern.operator}): {values_str}"
210
+ )
211
+
212
+ return AnalysisResult(
213
+ pattern_type="membership_validation",
214
+ string_values=pattern.string_values,
215
+ file_path=file_path,
216
+ line_number=pattern.line_number,
217
+ column=pattern.column,
218
+ variable_name=pattern.variable_name,
219
+ details=details,
220
+ )
221
+
222
+ def _convert_conditional_pattern(
223
+ self, pattern: EqualityChainPattern, file_path: Path
224
+ ) -> AnalysisResult:
225
+ """Convert an EqualityChainPattern to unified AnalysisResult.
226
+
227
+ Args:
228
+ pattern: Detected equality chain pattern
229
+ file_path: Path to the file containing the pattern
230
+
231
+ Returns:
232
+ AnalysisResult representing the pattern
233
+ """
234
+ values_str = ", ".join(sorted(pattern.string_values))
235
+ var_info = f" on '{pattern.variable_name}'" if pattern.variable_name else ""
236
+ pattern_label = self._get_pattern_label(pattern.pattern_type)
237
+ details = (
238
+ f"{pattern_label}{var_info} with {len(pattern.string_values)} "
239
+ f"string values: {values_str}"
240
+ )
241
+
242
+ return AnalysisResult(
243
+ pattern_type=pattern.pattern_type,
244
+ string_values=pattern.string_values,
245
+ file_path=file_path,
246
+ line_number=pattern.line_number,
247
+ column=pattern.column,
248
+ variable_name=pattern.variable_name,
249
+ details=details,
250
+ )
251
+
252
+ def _get_pattern_label(self, pattern_type: str) -> str:
253
+ """Get human-readable label for a pattern type.
254
+
255
+ Args:
256
+ pattern_type: The pattern type string
257
+
258
+ Returns:
259
+ Human-readable label for the pattern
260
+ """
261
+ labels = {
262
+ "equality_chain": "Equality chain",
263
+ "or_combined": "Or-combined comparison",
264
+ "match_statement": "Match statement",
265
+ }
266
+ return labels.get(pattern_type, "Conditional pattern")
267
+
268
+ def analyze_function_calls(self, code: str, file_path: Path) -> list[FunctionCallResult]:
269
+ """Analyze Python code for function calls with string arguments.
270
+
271
+ Args:
272
+ code: Python source code to analyze
273
+ file_path: Path to the file being analyzed
274
+
275
+ Returns:
276
+ List of FunctionCallResult instances for each detected call
277
+ """
278
+ tree = self._parse_code(code)
279
+ if tree is None:
280
+ return []
281
+
282
+ call_patterns = self._call_tracker.find_patterns(tree)
283
+ return [self._convert_call_pattern(pattern, file_path) for pattern in call_patterns]
284
+
285
+ def _convert_call_pattern(
286
+ self, pattern: FunctionCallPattern, file_path: Path
287
+ ) -> FunctionCallResult:
288
+ """Convert a FunctionCallPattern to FunctionCallResult.
289
+
290
+ Args:
291
+ pattern: Detected function call pattern
292
+ file_path: Path to the file containing the call
293
+
294
+ Returns:
295
+ FunctionCallResult representing the call
296
+ """
297
+ return FunctionCallResult(
298
+ function_name=pattern.function_name,
299
+ param_index=pattern.param_index,
300
+ string_value=pattern.string_value,
301
+ file_path=file_path,
302
+ line_number=pattern.line_number,
303
+ column=pattern.column,
304
+ )
305
+
306
+ def analyze_comparisons(self, code: str, file_path: Path) -> list[ComparisonResult]:
307
+ """Analyze Python code for string comparisons.
308
+
309
+ Args:
310
+ code: Python source code to analyze
311
+ file_path: Path to the file being analyzed
312
+
313
+ Returns:
314
+ List of ComparisonResult instances for each detected comparison
315
+ """
316
+ tree = self._parse_code(code)
317
+ if tree is None:
318
+ return []
319
+
320
+ comparison_patterns = self._comparison_tracker.find_patterns(tree)
321
+ return [
322
+ self._convert_comparison_pattern(pattern, file_path) for pattern in comparison_patterns
323
+ ]
324
+
325
+ def _convert_comparison_pattern(
326
+ self, pattern: ComparisonPattern, file_path: Path
327
+ ) -> ComparisonResult:
328
+ """Convert a ComparisonPattern to ComparisonResult.
329
+
330
+ Args:
331
+ pattern: Detected comparison pattern
332
+ file_path: Path to the file containing the comparison
333
+
334
+ Returns:
335
+ ComparisonResult representing the comparison
336
+ """
337
+ return ComparisonResult(
338
+ variable_name=pattern.variable_name,
339
+ compared_value=pattern.compared_value,
340
+ operator=pattern.operator,
341
+ file_path=file_path,
342
+ line_number=pattern.line_number,
343
+ column=pattern.column,
344
+ )
@@ -0,0 +1,172 @@
1
+ """
2
+ Purpose: Detect function calls with string literal arguments in Python AST
3
+
4
+ Scope: Find function and method calls that consistently receive string arguments
5
+
6
+ Overview: Provides FunctionCallTracker class that traverses Python AST to find function
7
+ and method calls where string literals are passed as arguments. Tracks the function
8
+ name, parameter index, and string value to enable cross-file aggregation. When a
9
+ function is called with the same set of limited string values across files, it
10
+ suggests the parameter should be an enum. Handles both simple function calls
11
+ (foo("value")) and method calls (obj.method("value")).
12
+
13
+ Dependencies: ast module for AST parsing, dataclasses for pattern structure
14
+
15
+ Exports: FunctionCallTracker class, FunctionCallPattern dataclass
16
+
17
+ Interfaces: FunctionCallTracker.find_patterns(tree) -> list[FunctionCallPattern]
18
+
19
+ Implementation: AST NodeVisitor pattern with Call node handling for string arguments
20
+ """
21
+
22
+ import ast
23
+ from dataclasses import dataclass
24
+
25
+
26
+ @dataclass
27
+ class FunctionCallPattern:
28
+ """Represents a function call with a string literal argument.
29
+
30
+ Captures information about a function or method call where a string literal
31
+ is passed as an argument, enabling cross-file analysis to detect limited
32
+ value sets that should be enums.
33
+ """
34
+
35
+ function_name: str
36
+ """Fully qualified function name (e.g., 'process' or 'obj.method')."""
37
+
38
+ param_index: int
39
+ """Index of the parameter receiving the string value (0-indexed)."""
40
+
41
+ string_value: str
42
+ """The string literal value passed to the function."""
43
+
44
+ line_number: int
45
+ """Line number where the call occurs (1-indexed)."""
46
+
47
+ column: int
48
+ """Column number where the call starts (0-indexed)."""
49
+
50
+
51
+ class FunctionCallTracker(ast.NodeVisitor):
52
+ """Tracks function calls with string literal arguments.
53
+
54
+ Finds patterns like 'process("active")' and 'obj.set_status("pending")' where
55
+ string literals are used for arguments that could be enums.
56
+ """
57
+
58
+ def __init__(self) -> None:
59
+ """Initialize the tracker."""
60
+ self.patterns: list[FunctionCallPattern] = []
61
+
62
+ def find_patterns(self, tree: ast.AST) -> list[FunctionCallPattern]:
63
+ """Find all function calls with string arguments in the AST.
64
+
65
+ Args:
66
+ tree: The AST to analyze
67
+
68
+ Returns:
69
+ List of FunctionCallPattern instances for each detected call
70
+ """
71
+ self.patterns = []
72
+ self.visit(tree)
73
+ return self.patterns
74
+
75
+ def visit_Call(self, node: ast.Call) -> None: # pylint: disable=invalid-name
76
+ """Visit a Call node to check for string arguments.
77
+
78
+ Handles both simple function calls and method calls, extracting
79
+ the function name and any string literal arguments.
80
+
81
+ Args:
82
+ node: The Call node to analyze
83
+ """
84
+ function_name = self._extract_function_name(node.func)
85
+ if function_name is None:
86
+ self.generic_visit(node)
87
+ return
88
+
89
+ self._check_positional_args(node, function_name)
90
+ self.generic_visit(node)
91
+
92
+ def _extract_function_name(self, func_node: ast.expr) -> str | None:
93
+ """Extract the function name from a call expression.
94
+
95
+ Handles simple names (foo) and attribute access (obj.method).
96
+
97
+ Args:
98
+ func_node: The function expression node
99
+
100
+ Returns:
101
+ Function name string or None if not extractable
102
+ """
103
+ if isinstance(func_node, ast.Name):
104
+ return func_node.id
105
+ if isinstance(func_node, ast.Attribute):
106
+ return self._extract_attribute_name(func_node)
107
+ return None
108
+
109
+ def _extract_attribute_name(self, node: ast.Attribute) -> str | None:
110
+ """Extract function name from an attribute access.
111
+
112
+ Builds qualified names like 'obj.method' or 'a.b.method'.
113
+
114
+ Args:
115
+ node: The Attribute node
116
+
117
+ Returns:
118
+ Qualified function name or None if too complex
119
+ """
120
+ parts: list[str] = [node.attr]
121
+ current = node.value
122
+
123
+ # Limit depth to avoid overly complex names
124
+ max_depth = 3
125
+ depth = 0
126
+
127
+ while depth < max_depth:
128
+ if isinstance(current, ast.Name):
129
+ parts.append(current.id)
130
+ break
131
+ if isinstance(current, ast.Attribute):
132
+ parts.append(current.attr)
133
+ current = current.value
134
+ depth += 1
135
+ else:
136
+ # Complex expression (call result, subscript, etc.)
137
+ # Use placeholder to maintain function identity
138
+ parts.append("_")
139
+ break
140
+
141
+ return ".".join(reversed(parts))
142
+
143
+ def _check_positional_args(self, node: ast.Call, function_name: str) -> None:
144
+ """Check positional arguments for string literals.
145
+
146
+ Args:
147
+ node: The Call node
148
+ function_name: Extracted function name
149
+ """
150
+ for param_index, arg in enumerate(node.args):
151
+ if isinstance(arg, ast.Constant) and isinstance(arg.value, str):
152
+ self._add_pattern(node, function_name, param_index, arg.value)
153
+
154
+ def _add_pattern(
155
+ self, node: ast.Call, function_name: str, param_index: int, string_value: str
156
+ ) -> None:
157
+ """Create and add a function call pattern to results.
158
+
159
+ Args:
160
+ node: The Call node containing the pattern
161
+ function_name: Name of the function being called
162
+ param_index: Index of the string argument
163
+ string_value: The string literal value
164
+ """
165
+ pattern = FunctionCallPattern(
166
+ function_name=function_name,
167
+ param_index=param_index,
168
+ string_value=string_value,
169
+ line_number=node.lineno,
170
+ column=node.col_offset,
171
+ )
172
+ self.patterns.append(pattern)