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,415 @@
1
+ """
2
+ Purpose: Detects single-statement patterns in Python code for DRY linter filtering
3
+
4
+ Scope: AST-based analysis to identify single logical statements that should not be flagged as duplicates
5
+
6
+ Overview: Provides sophisticated single-statement pattern detection to filter false positives in the
7
+ DRY linter. Analyzes Python AST to identify when a code block represents a single logical
8
+ statement (class field definitions, decorated functions, multi-line calls, assignments) that
9
+ should not be flagged as duplicate code. Uses line-to-node indexing for O(1) lookups and
10
+ supports various Python language constructs including classes, functions, decorators, and
11
+ nested structures.
12
+
13
+ Dependencies: ast module for Python AST parsing
14
+
15
+ Exports: SingleStatementDetector class
16
+
17
+ Interfaces: SingleStatementDetector.is_single_statement(content, start_line, end_line) -> bool
18
+
19
+ Implementation: AST walking with line-to-node index optimization for performance
20
+
21
+ SRP Exception: SingleStatementDetector has 33 methods and 308 lines (exceeds max 8 methods/200 lines)
22
+ Justification: Complex AST analysis algorithm for single-statement pattern detection with sophisticated
23
+ false positive filtering. Methods form tightly coupled algorithm pipeline: class field detection,
24
+ decorator handling, function call analysis, assignment patterns, and context-aware filtering. Similar
25
+ to parser or compiler pass architecture where algorithmic cohesion is critical. Splitting would
26
+ fragment the algorithm logic and make maintenance harder by separating interdependent AST analysis
27
+ steps. All methods contribute to single responsibility: accurately detecting single-statement patterns
28
+ to prevent false positives in duplicate code detection.
29
+ """
30
+
31
+ import ast
32
+ from collections.abc import Callable
33
+ from typing import cast
34
+
35
+ # AST context checking constants
36
+ AST_LOOKBACK_LINES = 10
37
+ AST_LOOKFORWARD_LINES = 5
38
+
39
+ # Type alias for AST nodes with line number attributes
40
+ ASTWithLineNumbers = ast.stmt | ast.expr
41
+
42
+
43
+ class SingleStatementDetector: # thailint: ignore[srp.violation]
44
+ """Detects single-statement patterns in Python code for duplicate filtering.
45
+
46
+ SRP suppression: Complex AST analysis algorithm requires 33 methods to implement
47
+ sophisticated single-statement detection with false positive filtering. See file header for justification.
48
+ """
49
+
50
+ def __init__(
51
+ self,
52
+ cached_ast: ast.Module | None = None,
53
+ cached_content: str | None = None,
54
+ line_to_nodes: dict[int, list[ast.AST]] | None = None,
55
+ ):
56
+ """Initialize detector with optional cached AST data.
57
+
58
+ Args:
59
+ cached_ast: Pre-parsed AST tree (for performance)
60
+ cached_content: Content that was parsed into cached_ast
61
+ line_to_nodes: Pre-built line-to-node index
62
+ """
63
+ self._cached_ast = cached_ast
64
+ self._cached_content = cached_content
65
+ self._line_to_nodes = line_to_nodes
66
+
67
+ def is_single_statement(self, content: str, start_line: int, end_line: int) -> bool:
68
+ """Check if a line range in the original source is a single logical statement.
69
+
70
+ Performance optimization: Uses cached AST if available to avoid re-parsing.
71
+
72
+ Args:
73
+ content: Source code content
74
+ start_line: Starting line number (1-indexed)
75
+ end_line: Ending line number (1-indexed)
76
+
77
+ Returns:
78
+ True if range represents a single logical statement
79
+ """
80
+ tree = self._get_ast_tree(content)
81
+ if tree is None:
82
+ return False
83
+
84
+ return self._check_overlapping_nodes(tree, start_line, end_line)
85
+
86
+ def _get_ast_tree(self, content: str) -> ast.Module | None:
87
+ """Get AST tree, using cache if available."""
88
+ if self._cached_ast is not None and content == self._cached_content:
89
+ return self._cached_ast
90
+ return self._parse_content_safe(content)
91
+
92
+ @staticmethod
93
+ def _parse_content_safe(content: str) -> ast.Module | None:
94
+ """Parse content, returning None on syntax error."""
95
+ try:
96
+ return ast.parse(content)
97
+ except SyntaxError:
98
+ return None
99
+
100
+ @staticmethod
101
+ def build_line_to_node_index(tree: ast.Module | None) -> dict[int, list[ast.AST]] | None:
102
+ """Build an index mapping each line number to overlapping AST nodes.
103
+
104
+ Performance optimization: Allows O(1) lookups instead of O(n) ast.walk() calls.
105
+
106
+ Args:
107
+ tree: Parsed AST tree (None if parsing failed)
108
+
109
+ Returns:
110
+ Dictionary mapping line numbers to list of AST nodes overlapping that line
111
+ """
112
+ if tree is None:
113
+ return None
114
+
115
+ line_to_nodes: dict[int, list[ast.AST]] = {}
116
+ for node in ast.walk(tree):
117
+ if SingleStatementDetector._node_has_line_info(node):
118
+ SingleStatementDetector._add_node_to_index(node, line_to_nodes)
119
+
120
+ return line_to_nodes
121
+
122
+ @staticmethod
123
+ def _node_has_line_info(node: ast.AST) -> bool:
124
+ """Check if node has valid line number information."""
125
+ if not hasattr(node, "lineno") or not hasattr(node, "end_lineno"):
126
+ return False
127
+ return node.lineno is not None and node.end_lineno is not None
128
+
129
+ @staticmethod
130
+ def _add_node_to_index(node: ast.AST, line_to_nodes: dict[int, list[ast.AST]]) -> None:
131
+ """Add node to all lines it overlaps in the index."""
132
+ for line_num in range(node.lineno, node.end_lineno + 1): # type: ignore[attr-defined]
133
+ if line_num not in line_to_nodes:
134
+ line_to_nodes[line_num] = []
135
+ line_to_nodes[line_num].append(node)
136
+
137
+ def _check_overlapping_nodes(self, tree: ast.Module, start_line: int, end_line: int) -> bool:
138
+ """Check if any AST node overlaps and matches single-statement pattern."""
139
+ if self._line_to_nodes is not None:
140
+ return self._check_nodes_via_index(start_line, end_line)
141
+ return self._check_nodes_via_walk(tree, start_line, end_line)
142
+
143
+ def _check_nodes_via_index(self, start_line: int, end_line: int) -> bool:
144
+ """Check nodes using line-to-node index for O(1) lookups."""
145
+ candidates = self._collect_candidate_nodes(start_line, end_line)
146
+ return self._any_node_matches_pattern(candidates, start_line, end_line)
147
+
148
+ def _collect_candidate_nodes(self, start_line: int, end_line: int) -> set[ast.AST]:
149
+ """Collect unique nodes that overlap with the line range from index."""
150
+ candidate_nodes: set[ast.AST] = set()
151
+ for line_num in range(start_line, end_line + 1):
152
+ if self._line_to_nodes and line_num in self._line_to_nodes:
153
+ candidate_nodes.update(self._line_to_nodes[line_num])
154
+ return candidate_nodes
155
+
156
+ def _any_node_matches_pattern(
157
+ self, nodes: set[ast.AST], start_line: int, end_line: int
158
+ ) -> bool:
159
+ """Check if any node matches single-statement pattern."""
160
+ for node in nodes:
161
+ if self._is_single_statement_pattern(node, start_line, end_line):
162
+ return True
163
+ return False
164
+
165
+ def _check_nodes_via_walk(self, tree: ast.Module, start_line: int, end_line: int) -> bool:
166
+ """Check nodes using ast.walk() fallback."""
167
+ for node in ast.walk(tree):
168
+ if self._node_matches_via_walk(node, start_line, end_line):
169
+ return True
170
+ return False
171
+
172
+ def _node_matches_via_walk(self, node: ast.AST, start_line: int, end_line: int) -> bool:
173
+ """Check if a single node overlaps and matches pattern."""
174
+ if not self._node_overlaps_range(node, start_line, end_line):
175
+ return False
176
+ return self._is_single_statement_pattern(node, start_line, end_line)
177
+
178
+ @staticmethod
179
+ def _node_overlaps_range(node: ast.AST, start_line: int, end_line: int) -> bool:
180
+ """Check if node overlaps with the given line range."""
181
+ if not hasattr(node, "lineno") or not hasattr(node, "end_lineno"):
182
+ return False
183
+ node_end = node.end_lineno
184
+ node_start = node.lineno
185
+ return not (node_end < start_line or node_start > end_line)
186
+
187
+ def _is_single_statement_pattern(self, node: ast.AST, start_line: int, end_line: int) -> bool:
188
+ """Check if an AST node represents a single-statement pattern to filter."""
189
+ contains = self._node_contains_range(node, start_line, end_line)
190
+ if contains is None:
191
+ return False
192
+
193
+ return self._dispatch_pattern_check(node, start_line, end_line, contains)
194
+
195
+ def _node_contains_range(self, node: ast.AST, start_line: int, end_line: int) -> bool | None:
196
+ """Check if node completely contains the range. Returns None if invalid."""
197
+ if not self._has_valid_line_numbers(node):
198
+ return None
199
+ typed_node = cast(ASTWithLineNumbers, node)
200
+ return typed_node.lineno <= start_line and typed_node.end_lineno >= end_line # type: ignore[operator]
201
+
202
+ @staticmethod
203
+ def _has_valid_line_numbers(node: ast.AST) -> bool:
204
+ """Check if node has valid line number attributes."""
205
+ if not (hasattr(node, "lineno") and hasattr(node, "end_lineno")):
206
+ return False
207
+ return node.lineno is not None and node.end_lineno is not None
208
+
209
+ def _dispatch_pattern_check(
210
+ self, node: ast.AST, start_line: int, end_line: int, contains: bool
211
+ ) -> bool:
212
+ """Dispatch to node-type-specific pattern checkers."""
213
+ if isinstance(node, ast.Expr):
214
+ return contains
215
+
216
+ return self._check_specific_pattern(node, start_line, end_line, contains)
217
+
218
+ def _check_specific_pattern(
219
+ self, node: ast.AST, start_line: int, end_line: int, contains: bool
220
+ ) -> bool:
221
+ """Check specific node types with their pattern rules."""
222
+ if isinstance(node, ast.ClassDef):
223
+ return self._check_class_def_pattern(node, start_line, end_line)
224
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
225
+ return self._check_function_def_pattern(node, start_line, end_line)
226
+ if isinstance(node, ast.Call):
227
+ return self._check_call_pattern(node, start_line, end_line, contains)
228
+ if isinstance(node, ast.Assign):
229
+ return self._check_assign_pattern(node, start_line, end_line, contains)
230
+ return False
231
+
232
+ def _check_class_def_pattern(self, node: ast.ClassDef, start_line: int, end_line: int) -> bool:
233
+ """Check if range is in class field definitions (not method bodies)."""
234
+ first_method_line = self._find_first_method_line(node)
235
+ class_start = self._get_class_start_with_decorators(node)
236
+ return self._is_in_class_fields_area(
237
+ class_start, start_line, end_line, first_method_line, node.end_lineno
238
+ )
239
+
240
+ @staticmethod
241
+ def _find_first_method_line(node: ast.ClassDef) -> int | None:
242
+ """Find line number of first method in class."""
243
+ for item in node.body:
244
+ if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
245
+ return item.lineno
246
+ return None
247
+
248
+ @staticmethod
249
+ def _get_class_start_with_decorators(node: ast.ClassDef) -> int:
250
+ """Get class start line, including decorators if present."""
251
+ if node.decorator_list:
252
+ return min(d.lineno for d in node.decorator_list)
253
+ return node.lineno
254
+
255
+ @staticmethod
256
+ def _is_in_class_fields_area(
257
+ class_start: int,
258
+ start_line: int,
259
+ end_line: int,
260
+ first_method_line: int | None,
261
+ class_end_line: int | None,
262
+ ) -> bool:
263
+ """Check if range is in class fields area (before methods)."""
264
+ if first_method_line is not None:
265
+ return class_start <= start_line and end_line < first_method_line
266
+ if class_end_line is not None:
267
+ return class_start <= start_line and class_end_line >= end_line
268
+ return False
269
+
270
+ def _check_function_def_pattern(
271
+ self, node: ast.FunctionDef | ast.AsyncFunctionDef, start_line: int, end_line: int
272
+ ) -> bool:
273
+ """Check if range is in function decorator pattern."""
274
+ if not node.decorator_list:
275
+ return False
276
+
277
+ first_decorator_line = min(d.lineno for d in node.decorator_list)
278
+ first_body_line = self._get_function_body_start(node)
279
+
280
+ if first_body_line is None:
281
+ return False
282
+
283
+ return start_line >= first_decorator_line and end_line < first_body_line
284
+
285
+ @staticmethod
286
+ def _get_function_body_start(node: ast.FunctionDef | ast.AsyncFunctionDef) -> int | None:
287
+ """Get the line number where function body starts."""
288
+ if not node.body or not hasattr(node.body[0], "lineno"):
289
+ return None
290
+ return node.body[0].lineno
291
+
292
+ def _check_call_pattern(
293
+ self, node: ast.Call, start_line: int, end_line: int, contains: bool
294
+ ) -> bool:
295
+ """Check if range is part of a function/constructor call."""
296
+ return self._check_multiline_or_contained(node, start_line, end_line, contains)
297
+
298
+ def _check_assign_pattern(
299
+ self, node: ast.Assign, start_line: int, end_line: int, contains: bool
300
+ ) -> bool:
301
+ """Check if range is part of a multi-line assignment."""
302
+ return self._check_multiline_or_contained(node, start_line, end_line, contains)
303
+
304
+ def _check_multiline_or_contained(
305
+ self, node: ast.AST, start_line: int, end_line: int, contains: bool
306
+ ) -> bool:
307
+ """Check if node is multiline containing start, or single-line containing range."""
308
+ if not self._has_valid_line_numbers(node):
309
+ return False
310
+
311
+ typed_node = cast(ASTWithLineNumbers, node)
312
+ is_multiline = typed_node.lineno < typed_node.end_lineno # type: ignore[operator]
313
+ if is_multiline:
314
+ return typed_node.lineno <= start_line <= typed_node.end_lineno # type: ignore[operator]
315
+ return contains
316
+
317
+ def is_standalone_single_statement(
318
+ self, lines: list[str], start_line: int, end_line: int
319
+ ) -> bool:
320
+ """Check if the exact range parses as a single statement on its own."""
321
+ source_lines = lines[start_line - 1 : end_line]
322
+ source_snippet = "\n".join(source_lines)
323
+
324
+ try:
325
+ tree = ast.parse(source_snippet)
326
+ return len(tree.body) == 1
327
+ except SyntaxError:
328
+ return False
329
+
330
+ def check_ast_context( # pylint: disable=too-many-arguments,too-many-positional-arguments
331
+ self,
332
+ lines: list[str],
333
+ start_line: int,
334
+ end_line: int,
335
+ lookback: int,
336
+ lookforward: int,
337
+ predicate: Callable[[ast.Module, int], bool],
338
+ ) -> bool:
339
+ """Generic helper for AST-based context checking.
340
+
341
+ Args:
342
+ lines: Source file lines
343
+ start_line: Starting line number (1-indexed)
344
+ end_line: Ending line number (1-indexed)
345
+ lookback: Number of lines to look backward
346
+ lookforward: Number of lines to look forward
347
+ predicate: Function that takes AST tree and lookback_start, returns bool
348
+
349
+ Returns:
350
+ True if predicate returns True for the parsed context
351
+ """
352
+ lookback_start = max(0, start_line - lookback)
353
+ lookforward_end = min(len(lines), end_line + lookforward)
354
+
355
+ context_lines = lines[lookback_start:lookforward_end]
356
+ context = "\n".join(context_lines)
357
+
358
+ try:
359
+ tree = ast.parse(context)
360
+ return predicate(tree, lookback_start)
361
+ except SyntaxError:
362
+ pass
363
+
364
+ return False
365
+
366
+ def is_part_of_decorator(self, lines: list[str], start_line: int, end_line: int) -> bool:
367
+ """Check if lines are part of a decorator + function definition."""
368
+
369
+ def has_decorators(tree: ast.Module, _lookback_start: int) -> bool:
370
+ """Check if any function or class in the tree has decorators."""
371
+ for stmt in tree.body:
372
+ if isinstance(stmt, (ast.FunctionDef, ast.ClassDef)) and stmt.decorator_list:
373
+ return True
374
+ return False
375
+
376
+ return self.check_ast_context(lines, start_line, end_line, 10, 10, has_decorators)
377
+
378
+ def is_part_of_function_call(self, lines: list[str], start_line: int, end_line: int) -> bool:
379
+ """Check if lines are arguments inside a function/constructor call."""
380
+
381
+ def is_single_non_function_statement(tree: ast.Module, _lookback_start: int) -> bool:
382
+ """Check if context has exactly one statement that's not a function/class def."""
383
+ return len(tree.body) == 1 and not isinstance(
384
+ tree.body[0], (ast.FunctionDef, ast.ClassDef)
385
+ )
386
+
387
+ return self.check_ast_context(
388
+ lines, start_line, end_line, 10, 10, is_single_non_function_statement
389
+ )
390
+
391
+ def is_part_of_class_body(self, lines: list[str], start_line: int, end_line: int) -> bool:
392
+ """Check if lines are field definitions inside a class body."""
393
+
394
+ def is_within_class_body(tree: ast.Module, lookback_start: int) -> bool:
395
+ """Check if flagged range falls within a class body."""
396
+ class_defs = (s for s in tree.body if isinstance(s, ast.ClassDef))
397
+ for stmt in class_defs:
398
+ class_start_in_context = stmt.lineno
399
+ class_end_in_context = stmt.end_lineno if stmt.end_lineno else stmt.lineno
400
+
401
+ class_start_original = lookback_start + class_start_in_context
402
+ class_end_original = lookback_start + class_end_in_context
403
+
404
+ if start_line >= class_start_original and end_line <= class_end_original:
405
+ return True
406
+ return False
407
+
408
+ return self.check_ast_context(
409
+ lines,
410
+ start_line,
411
+ end_line,
412
+ AST_LOOKBACK_LINES,
413
+ AST_LOOKFORWARD_LINES,
414
+ is_within_class_body,
415
+ )
@@ -130,6 +130,10 @@ class TokenHasher: # thailint: ignore[srp] - Methods support single responsibil
130
130
 
131
131
  return line
132
132
 
133
+ # Pre-compiled import token set for O(1) membership test
134
+ _IMPORT_TOKENS: frozenset[str] = frozenset(("{", "}", "} from"))
135
+ _IMPORT_PREFIXES: tuple[str, ...] = ("import ", "from ", "export ")
136
+
133
137
  def _is_import_statement(self, line: str) -> bool:
134
138
  """Check if line is an import statement.
135
139
 
@@ -139,11 +143,7 @@ class TokenHasher: # thailint: ignore[srp] - Methods support single responsibil
139
143
  Returns:
140
144
  True if line is an import statement
141
145
  """
142
- # Check all import/export patterns
143
- import_prefixes = ("import ", "from ", "export ")
144
- import_tokens = ("{", "}", "} from")
145
-
146
- return line.startswith(import_prefixes) or line in import_tokens
146
+ return line.startswith(self._IMPORT_PREFIXES) or line in self._IMPORT_TOKENS
147
147
 
148
148
  def rolling_hash(self, lines: list[str], window_size: int) -> list[tuple[int, int, int, str]]:
149
149
  """Create rolling hash windows over code lines.