thailint 0.1.5__py3-none-any.whl → 0.5.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 (91) hide show
  1. src/__init__.py +7 -2
  2. src/analyzers/__init__.py +23 -0
  3. src/analyzers/typescript_base.py +148 -0
  4. src/api.py +1 -1
  5. src/cli.py +1111 -144
  6. src/config.py +12 -33
  7. src/core/base.py +102 -5
  8. src/core/cli_utils.py +206 -0
  9. src/core/config_parser.py +126 -0
  10. src/core/linter_utils.py +168 -0
  11. src/core/registry.py +17 -92
  12. src/core/rule_discovery.py +132 -0
  13. src/core/violation_builder.py +122 -0
  14. src/linter_config/ignore.py +112 -40
  15. src/linter_config/loader.py +3 -13
  16. src/linters/dry/__init__.py +23 -0
  17. src/linters/dry/base_token_analyzer.py +76 -0
  18. src/linters/dry/block_filter.py +265 -0
  19. src/linters/dry/block_grouper.py +59 -0
  20. src/linters/dry/cache.py +172 -0
  21. src/linters/dry/cache_query.py +61 -0
  22. src/linters/dry/config.py +134 -0
  23. src/linters/dry/config_loader.py +44 -0
  24. src/linters/dry/deduplicator.py +120 -0
  25. src/linters/dry/duplicate_storage.py +63 -0
  26. src/linters/dry/file_analyzer.py +90 -0
  27. src/linters/dry/inline_ignore.py +140 -0
  28. src/linters/dry/linter.py +163 -0
  29. src/linters/dry/python_analyzer.py +668 -0
  30. src/linters/dry/storage_initializer.py +42 -0
  31. src/linters/dry/token_hasher.py +169 -0
  32. src/linters/dry/typescript_analyzer.py +592 -0
  33. src/linters/dry/violation_builder.py +74 -0
  34. src/linters/dry/violation_filter.py +94 -0
  35. src/linters/dry/violation_generator.py +174 -0
  36. src/linters/file_header/__init__.py +24 -0
  37. src/linters/file_header/atemporal_detector.py +87 -0
  38. src/linters/file_header/config.py +66 -0
  39. src/linters/file_header/field_validator.py +69 -0
  40. src/linters/file_header/linter.py +313 -0
  41. src/linters/file_header/python_parser.py +86 -0
  42. src/linters/file_header/violation_builder.py +78 -0
  43. src/linters/file_placement/config_loader.py +86 -0
  44. src/linters/file_placement/directory_matcher.py +80 -0
  45. src/linters/file_placement/linter.py +262 -471
  46. src/linters/file_placement/path_resolver.py +61 -0
  47. src/linters/file_placement/pattern_matcher.py +55 -0
  48. src/linters/file_placement/pattern_validator.py +106 -0
  49. src/linters/file_placement/rule_checker.py +229 -0
  50. src/linters/file_placement/violation_factory.py +177 -0
  51. src/linters/magic_numbers/__init__.py +48 -0
  52. src/linters/magic_numbers/config.py +82 -0
  53. src/linters/magic_numbers/context_analyzer.py +247 -0
  54. src/linters/magic_numbers/linter.py +516 -0
  55. src/linters/magic_numbers/python_analyzer.py +76 -0
  56. src/linters/magic_numbers/typescript_analyzer.py +218 -0
  57. src/linters/magic_numbers/violation_builder.py +98 -0
  58. src/linters/nesting/__init__.py +6 -2
  59. src/linters/nesting/config.py +17 -4
  60. src/linters/nesting/linter.py +81 -168
  61. src/linters/nesting/typescript_analyzer.py +39 -102
  62. src/linters/nesting/typescript_function_extractor.py +130 -0
  63. src/linters/nesting/violation_builder.py +139 -0
  64. src/linters/print_statements/__init__.py +53 -0
  65. src/linters/print_statements/config.py +83 -0
  66. src/linters/print_statements/linter.py +430 -0
  67. src/linters/print_statements/python_analyzer.py +155 -0
  68. src/linters/print_statements/typescript_analyzer.py +135 -0
  69. src/linters/print_statements/violation_builder.py +98 -0
  70. src/linters/srp/__init__.py +99 -0
  71. src/linters/srp/class_analyzer.py +113 -0
  72. src/linters/srp/config.py +82 -0
  73. src/linters/srp/heuristics.py +89 -0
  74. src/linters/srp/linter.py +234 -0
  75. src/linters/srp/metrics_evaluator.py +47 -0
  76. src/linters/srp/python_analyzer.py +72 -0
  77. src/linters/srp/typescript_analyzer.py +75 -0
  78. src/linters/srp/typescript_metrics_calculator.py +90 -0
  79. src/linters/srp/violation_builder.py +117 -0
  80. src/orchestrator/core.py +54 -9
  81. src/templates/thailint_config_template.yaml +158 -0
  82. src/utils/__init__.py +4 -0
  83. src/utils/project_root.py +203 -0
  84. thailint-0.5.0.dist-info/METADATA +1286 -0
  85. thailint-0.5.0.dist-info/RECORD +96 -0
  86. {thailint-0.1.5.dist-info → thailint-0.5.0.dist-info}/WHEEL +1 -1
  87. src/.ai/layout.yaml +0 -48
  88. thailint-0.1.5.dist-info/METADATA +0 -629
  89. thailint-0.1.5.dist-info/RECORD +0 -28
  90. {thailint-0.1.5.dist-info → thailint-0.5.0.dist-info}/entry_points.txt +0 -0
  91. {thailint-0.1.5.dist-info → thailint-0.5.0.dist-info/licenses}/LICENSE +0 -0
@@ -0,0 +1,668 @@
1
+ """
2
+ Purpose: Python source code tokenization and duplicate block analysis
3
+
4
+ Scope: Python-specific code analysis for duplicate detection
5
+
6
+ Overview: Analyzes Python source files to extract code blocks for duplicate detection. Inherits
7
+ from BaseTokenAnalyzer to reuse common token-based hashing and rolling hash window logic.
8
+ Filters out docstrings at the tokenization level to prevent false positive duplication
9
+ detection on documentation strings.
10
+
11
+ Dependencies: BaseTokenAnalyzer, CodeBlock, DRYConfig, pathlib.Path, ast, TokenHasher
12
+
13
+ Exports: PythonDuplicateAnalyzer class
14
+
15
+ Interfaces: PythonDuplicateAnalyzer.analyze(file_path: Path, content: str, config: DRYConfig)
16
+ -> list[CodeBlock]
17
+
18
+ Implementation: Uses custom tokenizer that filters docstrings before hashing
19
+
20
+ SRP Exception: PythonDuplicateAnalyzer has 32 methods and 358 lines (exceeds max 8 methods/200 lines)
21
+ Justification: Complex AST analysis algorithm for duplicate code detection with sophisticated
22
+ false positive filtering. Methods form tightly coupled algorithm pipeline: docstring extraction,
23
+ tokenization with line tracking, single-statement pattern detection across 5+ AST node types
24
+ (ClassDef, FunctionDef, Call, Assign, Expr), and context-aware filtering (decorators, function
25
+ calls, class bodies). Similar to parser or compiler pass architecture where algorithmic
26
+ cohesion is critical. Splitting would fragment the algorithm logic and make maintenance
27
+ harder by separating interdependent AST analysis steps. All methods contribute to single
28
+ responsibility: accurately detecting duplicate Python code while minimizing false positives.
29
+ """
30
+
31
+ import ast
32
+ from collections.abc import Callable
33
+ from pathlib import Path
34
+ from typing import cast
35
+
36
+ from .base_token_analyzer import BaseTokenAnalyzer
37
+ from .block_filter import BlockFilterRegistry, create_default_registry
38
+ from .cache import CodeBlock
39
+ from .config import DRYConfig
40
+
41
+ # AST context checking constants
42
+ AST_LOOKBACK_LINES = 10
43
+ AST_LOOKFORWARD_LINES = 5
44
+
45
+ # Type alias for AST nodes that have line number attributes
46
+ # All stmt and expr nodes have lineno and end_lineno after parsing
47
+ ASTWithLineNumbers = ast.stmt | ast.expr
48
+
49
+
50
+ class PythonDuplicateAnalyzer(BaseTokenAnalyzer): # thailint: ignore[srp.violation]
51
+ """Analyzes Python code for duplicate blocks, excluding docstrings.
52
+
53
+ SRP suppression: Complex AST analysis algorithm requires 32 methods to implement
54
+ sophisticated duplicate detection with false positive filtering. See file header for justification.
55
+ """
56
+
57
+ def __init__(self, filter_registry: BlockFilterRegistry | None = None):
58
+ """Initialize analyzer with optional custom filter registry.
59
+
60
+ Args:
61
+ filter_registry: Custom filter registry (uses defaults if None)
62
+ """
63
+ super().__init__()
64
+ self._filter_registry = filter_registry or create_default_registry()
65
+ # Performance optimization: Cache parsed AST to avoid re-parsing for each hash window
66
+ self._cached_ast: ast.Module | None = None
67
+ self._cached_content: str | None = None
68
+ # Performance optimization: Line-to-node index for O(1) lookups instead of O(n) ast.walk()
69
+ self._line_to_nodes: dict[int, list[ast.AST]] | None = None
70
+
71
+ def analyze( # thailint: ignore[nesting.excessive-depth]
72
+ self, file_path: Path, content: str, config: DRYConfig
73
+ ) -> list[CodeBlock]:
74
+ """Analyze Python file for duplicate code blocks, excluding docstrings.
75
+
76
+ Args:
77
+ file_path: Path to source file
78
+ content: File content
79
+ config: DRY configuration
80
+
81
+ Returns:
82
+ List of CodeBlock instances with hash values
83
+ """
84
+ # Performance optimization: Parse AST once and cache for _is_single_statement_in_source() calls
85
+ self._cached_ast = self._parse_content_safe(content)
86
+ self._cached_content = content
87
+
88
+ # Performance optimization: Build line-to-node index for O(1) lookups
89
+ self._line_to_nodes = self._build_line_to_node_index(self._cached_ast)
90
+
91
+ try:
92
+ # Get docstring line ranges
93
+ docstring_ranges = self._get_docstring_ranges_from_content(content)
94
+
95
+ # Tokenize with line number tracking
96
+ lines_with_numbers = self._tokenize_with_line_numbers(content, docstring_ranges)
97
+
98
+ # Generate rolling hash windows
99
+ windows = self._rolling_hash_with_tracking(
100
+ lines_with_numbers, config.min_duplicate_lines
101
+ )
102
+
103
+ return self._filter_valid_blocks(windows, file_path, content)
104
+ finally:
105
+ # Clear cache after analysis to avoid memory leaks
106
+ self._cached_ast = None
107
+ self._cached_content = None
108
+ self._line_to_nodes = None
109
+
110
+ def _filter_valid_blocks(
111
+ self,
112
+ windows: list[tuple[int, int, int, str]],
113
+ file_path: Path,
114
+ content: str,
115
+ ) -> list[CodeBlock]:
116
+ """Filter hash windows and create valid CodeBlock instances."""
117
+ blocks = []
118
+ for hash_val, start_line, end_line, snippet in windows:
119
+ block = self._create_block_if_valid(
120
+ file_path, content, hash_val, start_line, end_line, snippet
121
+ )
122
+ if block:
123
+ blocks.append(block)
124
+ return blocks
125
+
126
+ def _create_block_if_valid( # pylint: disable=too-many-arguments,too-many-positional-arguments
127
+ self,
128
+ file_path: Path,
129
+ content: str,
130
+ hash_val: int,
131
+ start_line: int,
132
+ end_line: int,
133
+ snippet: str,
134
+ ) -> CodeBlock | None:
135
+ """Create CodeBlock if it passes all validation checks."""
136
+ if self._is_single_statement_in_source(content, start_line, end_line):
137
+ return None
138
+
139
+ block = CodeBlock(
140
+ file_path=file_path,
141
+ start_line=start_line,
142
+ end_line=end_line,
143
+ snippet=snippet,
144
+ hash_value=hash_val,
145
+ )
146
+
147
+ if self._filter_registry.should_filter_block(block, content):
148
+ return None
149
+
150
+ return block
151
+
152
+ def _get_docstring_ranges_from_content(self, content: str) -> set[int]:
153
+ """Extract line numbers that are part of docstrings.
154
+
155
+ Args:
156
+ content: Python source code
157
+
158
+ Returns:
159
+ Set of line numbers (1-indexed) that are part of docstrings
160
+ """
161
+ try:
162
+ tree = ast.parse(content)
163
+ except SyntaxError:
164
+ return set()
165
+
166
+ docstring_lines: set[int] = set()
167
+ for node in ast.walk(tree):
168
+ self._extract_docstring_lines(node, docstring_lines)
169
+
170
+ return docstring_lines
171
+
172
+ def _extract_docstring_lines(self, node: ast.AST, docstring_lines: set[int]) -> None:
173
+ """Extract docstring line numbers from a node."""
174
+ docstring = self._get_docstring_safe(node)
175
+ if not docstring:
176
+ return
177
+
178
+ if not hasattr(node, "body") or not node.body:
179
+ return
180
+
181
+ first_stmt = node.body[0]
182
+ if self._is_docstring_node(first_stmt):
183
+ self._add_line_range(first_stmt, docstring_lines)
184
+
185
+ @staticmethod
186
+ def _get_docstring_safe(node: ast.AST) -> str | None:
187
+ """Safely get docstring from node, returning None on error."""
188
+ try:
189
+ return ast.get_docstring(node, clean=False) # type: ignore[arg-type]
190
+ except TypeError:
191
+ return None
192
+
193
+ @staticmethod
194
+ def _is_docstring_node(node: ast.stmt) -> bool:
195
+ """Check if a statement node is a docstring."""
196
+ return isinstance(node, ast.Expr) and isinstance(node.value, ast.Constant)
197
+
198
+ @staticmethod
199
+ def _add_line_range(node: ast.stmt, line_set: set[int]) -> None:
200
+ """Add all line numbers from node's line range to the set."""
201
+ if node.lineno and node.end_lineno:
202
+ for line_num in range(node.lineno, node.end_lineno + 1):
203
+ line_set.add(line_num)
204
+
205
+ def _tokenize_with_line_numbers(
206
+ self, content: str, docstring_lines: set[int]
207
+ ) -> list[tuple[int, str]]:
208
+ """Tokenize code while tracking original line numbers and skipping docstrings.
209
+
210
+ Args:
211
+ content: Source code
212
+ docstring_lines: Set of line numbers that are docstrings
213
+
214
+ Returns:
215
+ List of (original_line_number, normalized_code) tuples
216
+ """
217
+ lines_with_numbers = []
218
+ in_multiline_import = False
219
+
220
+ for line_num, line in enumerate(content.split("\n"), start=1):
221
+ if line_num in docstring_lines:
222
+ continue
223
+
224
+ line = self._hasher._normalize_line(line) # pylint: disable=protected-access
225
+ if not line:
226
+ continue
227
+
228
+ # Update multi-line import state and check if line should be skipped
229
+ in_multiline_import, should_skip = self._hasher._should_skip_import_line( # pylint: disable=protected-access
230
+ line, in_multiline_import
231
+ )
232
+ if should_skip:
233
+ continue
234
+
235
+ lines_with_numbers.append((line_num, line))
236
+
237
+ return lines_with_numbers
238
+
239
+ def _rolling_hash_with_tracking(
240
+ self, lines_with_numbers: list[tuple[int, str]], window_size: int
241
+ ) -> list[tuple[int, int, int, str]]:
242
+ """Create rolling hash windows while preserving original line numbers.
243
+
244
+ Args:
245
+ lines_with_numbers: List of (line_number, code) tuples
246
+ window_size: Number of lines per window
247
+
248
+ Returns:
249
+ List of (hash_value, start_line, end_line, snippet) tuples
250
+ """
251
+ if len(lines_with_numbers) < window_size:
252
+ return []
253
+
254
+ hashes = []
255
+ for i in range(len(lines_with_numbers) - window_size + 1):
256
+ window = lines_with_numbers[i : i + window_size]
257
+
258
+ # Extract just the code for hashing
259
+ code_lines = [code for _, code in window]
260
+ snippet = "\n".join(code_lines)
261
+ hash_val = hash(snippet)
262
+
263
+ # Get original line numbers
264
+ start_line = window[0][0]
265
+ end_line = window[-1][0]
266
+
267
+ hashes.append((hash_val, start_line, end_line, snippet))
268
+
269
+ return hashes
270
+
271
+ def _is_single_statement_in_source(self, content: str, start_line: int, end_line: int) -> bool:
272
+ """Check if a line range in the original source is a single logical statement.
273
+
274
+ Performance optimization: Uses cached AST if available (set by analyze() method)
275
+ to avoid re-parsing the entire file for each hash window check.
276
+ """
277
+ # Use cached AST if available and content matches
278
+ tree: ast.Module | None
279
+ if self._cached_ast is not None and content == self._cached_content:
280
+ tree = self._cached_ast
281
+ else:
282
+ # Fallback: parse content (used by tests or standalone calls)
283
+ tree = self._parse_content_safe(content)
284
+ if tree is None:
285
+ return False
286
+
287
+ return self._check_overlapping_nodes(tree, start_line, end_line)
288
+
289
+ @staticmethod
290
+ def _parse_content_safe(content: str) -> ast.Module | None:
291
+ """Parse content, returning None on syntax error."""
292
+ try:
293
+ return ast.parse(content)
294
+ except SyntaxError:
295
+ return None
296
+
297
+ @staticmethod
298
+ def _build_line_to_node_index(tree: ast.Module | None) -> dict[int, list[ast.AST]] | None:
299
+ """Build an index mapping each line number to overlapping AST nodes.
300
+
301
+ Performance optimization: This allows O(1) lookups instead of O(n) ast.walk() calls.
302
+ For a file with 5,144 nodes and 673 hash windows, this reduces 3.46M node operations
303
+ to just ~3,365 relevant node checks (99.9% reduction).
304
+
305
+ Args:
306
+ tree: Parsed AST tree (None if parsing failed)
307
+
308
+ Returns:
309
+ Dictionary mapping line numbers to list of AST nodes overlapping that line,
310
+ or None if tree is None
311
+ """
312
+ if tree is None:
313
+ return None
314
+
315
+ line_to_nodes: dict[int, list[ast.AST]] = {}
316
+ for node in ast.walk(tree):
317
+ if PythonDuplicateAnalyzer._node_has_line_info(node):
318
+ PythonDuplicateAnalyzer._add_node_to_index(node, line_to_nodes)
319
+
320
+ return line_to_nodes
321
+
322
+ @staticmethod
323
+ def _node_has_line_info(node: ast.AST) -> bool:
324
+ """Check if node has valid line number information."""
325
+ if not hasattr(node, "lineno") or not hasattr(node, "end_lineno"):
326
+ return False
327
+ return node.lineno is not None and node.end_lineno is not None
328
+
329
+ @staticmethod
330
+ def _add_node_to_index(node: ast.AST, line_to_nodes: dict[int, list[ast.AST]]) -> None:
331
+ """Add node to all lines it overlaps in the index."""
332
+ for line_num in range(node.lineno, node.end_lineno + 1): # type: ignore[attr-defined]
333
+ if line_num not in line_to_nodes:
334
+ line_to_nodes[line_num] = []
335
+ line_to_nodes[line_num].append(node)
336
+
337
+ def _check_overlapping_nodes(self, tree: ast.Module, start_line: int, end_line: int) -> bool:
338
+ """Check if any AST node overlaps and matches single-statement pattern.
339
+
340
+ Performance optimization: Use line-to-node index for O(1) lookups instead of O(n) ast.walk().
341
+ """
342
+ if self._line_to_nodes is not None:
343
+ return self._check_nodes_via_index(start_line, end_line)
344
+ return self._check_nodes_via_walk(tree, start_line, end_line)
345
+
346
+ def _check_nodes_via_index(self, start_line: int, end_line: int) -> bool:
347
+ """Check nodes using line-to-node index for O(1) lookups."""
348
+ candidates = self._collect_candidate_nodes_from_index(start_line, end_line)
349
+ return self._any_node_matches_pattern(candidates, start_line, end_line)
350
+
351
+ def _collect_candidate_nodes_from_index(self, start_line: int, end_line: int) -> set[ast.AST]:
352
+ """Collect unique nodes that overlap with the line range from index."""
353
+ candidate_nodes: set[ast.AST] = set()
354
+ for line_num in range(start_line, end_line + 1):
355
+ if self._line_to_nodes and line_num in self._line_to_nodes:
356
+ candidate_nodes.update(self._line_to_nodes[line_num])
357
+ return candidate_nodes
358
+
359
+ def _any_node_matches_pattern(
360
+ self, nodes: set[ast.AST], start_line: int, end_line: int
361
+ ) -> bool:
362
+ """Check if any node matches single-statement pattern."""
363
+ for node in nodes:
364
+ if self._is_single_statement_pattern(node, start_line, end_line):
365
+ return True
366
+ return False
367
+
368
+ def _check_nodes_via_walk(self, tree: ast.Module, start_line: int, end_line: int) -> bool:
369
+ """Check nodes using ast.walk() fallback for tests or standalone calls."""
370
+ for node in ast.walk(tree):
371
+ if self._node_matches_via_walk(node, start_line, end_line):
372
+ return True
373
+ return False
374
+
375
+ def _node_matches_via_walk(self, node: ast.AST, start_line: int, end_line: int) -> bool:
376
+ """Check if a single node overlaps and matches pattern."""
377
+ if not self._node_overlaps_range(node, start_line, end_line):
378
+ return False
379
+ return self._is_single_statement_pattern(node, start_line, end_line)
380
+
381
+ @staticmethod
382
+ def _node_overlaps_range(node: ast.AST, start_line: int, end_line: int) -> bool:
383
+ """Check if node overlaps with the given line range."""
384
+ if not hasattr(node, "lineno") or not hasattr(node, "end_lineno"):
385
+ return False
386
+ node_end = node.end_lineno
387
+ node_start = node.lineno
388
+ return not (node_end < start_line or node_start > end_line)
389
+
390
+ def _node_overlaps_and_matches(self, node: ast.AST, start_line: int, end_line: int) -> bool:
391
+ """Check if node overlaps with range and matches single-statement pattern."""
392
+ if not hasattr(node, "lineno") or not hasattr(node, "end_lineno"):
393
+ return False
394
+
395
+ overlaps = not (node.end_lineno < start_line or node.lineno > end_line)
396
+ if not overlaps:
397
+ return False
398
+
399
+ return self._is_single_statement_pattern(node, start_line, end_line)
400
+
401
+ def _is_single_statement_pattern(self, node: ast.AST, start_line: int, end_line: int) -> bool:
402
+ """Check if an AST node represents a single-statement pattern to filter.
403
+
404
+ Args:
405
+ node: AST node that overlaps with the line range
406
+ start_line: Starting line number (1-indexed)
407
+ end_line: Ending line number (1-indexed)
408
+
409
+ Returns:
410
+ True if this node represents a single logical statement pattern
411
+ """
412
+ contains = self._node_contains_range(node, start_line, end_line)
413
+ if contains is None:
414
+ return False
415
+
416
+ return self._dispatch_pattern_check(node, start_line, end_line, contains)
417
+
418
+ def _node_contains_range(self, node: ast.AST, start_line: int, end_line: int) -> bool | None:
419
+ """Check if node completely contains the range. Returns None if invalid."""
420
+ if not self._has_valid_line_numbers(node):
421
+ return None
422
+ # Type narrowing: _has_valid_line_numbers ensures node has line numbers
423
+ # Safe to cast after validation check above
424
+ typed_node = cast(ASTWithLineNumbers, node)
425
+ # Use type: ignore to suppress MyPy's inability to understand runtime validation
426
+ return typed_node.lineno <= start_line and typed_node.end_lineno >= end_line # type: ignore[operator]
427
+
428
+ @staticmethod
429
+ def _has_valid_line_numbers(node: ast.AST) -> bool:
430
+ """Check if node has valid line number attributes."""
431
+ if not (hasattr(node, "lineno") and hasattr(node, "end_lineno")):
432
+ return False
433
+ return node.lineno is not None and node.end_lineno is not None
434
+
435
+ def _dispatch_pattern_check(
436
+ self, node: ast.AST, start_line: int, end_line: int, contains: bool
437
+ ) -> bool:
438
+ """Dispatch to node-type-specific pattern checkers."""
439
+ # Simple containment check for Expr nodes
440
+ if isinstance(node, ast.Expr):
441
+ return contains
442
+
443
+ # Delegate to specialized checkers
444
+ return self._check_specific_pattern(node, start_line, end_line, contains)
445
+
446
+ def _check_specific_pattern(
447
+ self, node: ast.AST, start_line: int, end_line: int, contains: bool
448
+ ) -> bool:
449
+ """Check specific node types with their pattern rules."""
450
+ if isinstance(node, ast.ClassDef):
451
+ return self._check_class_def_pattern(node, start_line, end_line)
452
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
453
+ return self._check_function_def_pattern(node, start_line, end_line)
454
+ if isinstance(node, ast.Call):
455
+ return self._check_call_pattern(node, start_line, end_line, contains)
456
+ if isinstance(node, ast.Assign):
457
+ return self._check_assign_pattern(node, start_line, end_line, contains)
458
+ return False
459
+
460
+ def _check_class_def_pattern(self, node: ast.ClassDef, start_line: int, end_line: int) -> bool:
461
+ """Check if range is in class field definitions (not method bodies)."""
462
+ first_method_line = self._find_first_method_line(node)
463
+ class_start = self._get_class_start_with_decorators(node)
464
+ return self._is_in_class_fields_area(
465
+ class_start, start_line, end_line, first_method_line, node.end_lineno
466
+ )
467
+
468
+ @staticmethod
469
+ def _find_first_method_line(node: ast.ClassDef) -> int | None:
470
+ """Find line number of first method in class."""
471
+ for item in node.body:
472
+ if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
473
+ return item.lineno
474
+ return None
475
+
476
+ @staticmethod
477
+ def _get_class_start_with_decorators(node: ast.ClassDef) -> int:
478
+ """Get class start line, including decorators if present."""
479
+ if node.decorator_list:
480
+ return min(d.lineno for d in node.decorator_list)
481
+ return node.lineno
482
+
483
+ @staticmethod
484
+ def _is_in_class_fields_area(
485
+ class_start: int,
486
+ start_line: int,
487
+ end_line: int,
488
+ first_method_line: int | None,
489
+ class_end_line: int | None,
490
+ ) -> bool:
491
+ """Check if range is in class fields area (before methods)."""
492
+ if first_method_line is not None:
493
+ return class_start <= start_line and end_line < first_method_line
494
+ if class_end_line is not None:
495
+ return class_start <= start_line and class_end_line >= end_line
496
+ return False
497
+
498
+ def _check_function_def_pattern(
499
+ self, node: ast.FunctionDef | ast.AsyncFunctionDef, start_line: int, end_line: int
500
+ ) -> bool:
501
+ """Check if range is in function decorator pattern."""
502
+ if not node.decorator_list:
503
+ return False
504
+
505
+ first_decorator_line = min(d.lineno for d in node.decorator_list)
506
+ first_body_line = self._get_function_body_start(node)
507
+
508
+ if first_body_line is None:
509
+ return False
510
+
511
+ return start_line >= first_decorator_line and end_line < first_body_line
512
+
513
+ @staticmethod
514
+ def _get_function_body_start(node: ast.FunctionDef | ast.AsyncFunctionDef) -> int | None:
515
+ """Get the line number where function body starts."""
516
+ if not node.body or not hasattr(node.body[0], "lineno"):
517
+ return None
518
+ return node.body[0].lineno
519
+
520
+ def _check_call_pattern(
521
+ self, node: ast.Call, start_line: int, end_line: int, contains: bool
522
+ ) -> bool:
523
+ """Check if range is part of a function/constructor call."""
524
+ return self._check_multiline_or_contained(node, start_line, end_line, contains)
525
+
526
+ def _check_assign_pattern(
527
+ self, node: ast.Assign, start_line: int, end_line: int, contains: bool
528
+ ) -> bool:
529
+ """Check if range is part of a multi-line assignment."""
530
+ return self._check_multiline_or_contained(node, start_line, end_line, contains)
531
+
532
+ def _check_multiline_or_contained(
533
+ self, node: ast.AST, start_line: int, end_line: int, contains: bool
534
+ ) -> bool:
535
+ """Check if node is multiline containing start, or single-line containing range."""
536
+ if not self._has_valid_line_numbers(node):
537
+ return False
538
+
539
+ # Type narrowing: _has_valid_line_numbers ensures node has line numbers
540
+ # Safe to cast after validation check above
541
+ typed_node = cast(ASTWithLineNumbers, node)
542
+ # Use type: ignore to suppress MyPy's inability to understand runtime validation
543
+ is_multiline = typed_node.lineno < typed_node.end_lineno # type: ignore[operator]
544
+ if is_multiline:
545
+ return typed_node.lineno <= start_line <= typed_node.end_lineno # type: ignore[operator]
546
+ return contains
547
+
548
+ def _is_standalone_single_statement(
549
+ self, lines: list[str], start_line: int, end_line: int
550
+ ) -> bool:
551
+ """Check if the exact range parses as a single statement on its own."""
552
+ source_lines = lines[start_line - 1 : end_line]
553
+ source_snippet = "\n".join(source_lines)
554
+
555
+ try:
556
+ tree = ast.parse(source_snippet)
557
+ return len(tree.body) == 1
558
+ except SyntaxError:
559
+ return False
560
+
561
+ def _check_ast_context( # pylint: disable=too-many-arguments,too-many-positional-arguments
562
+ self,
563
+ lines: list[str],
564
+ start_line: int,
565
+ end_line: int,
566
+ lookback: int,
567
+ lookforward: int,
568
+ predicate: Callable[[ast.Module, int], bool],
569
+ ) -> bool:
570
+ """Generic helper for AST-based context checking.
571
+
572
+ Args:
573
+ lines: Source file lines
574
+ start_line: Starting line number (1-indexed)
575
+ end_line: Ending line number (1-indexed)
576
+ lookback: Number of lines to look backward
577
+ lookforward: Number of lines to look forward
578
+ predicate: Function that takes AST tree and returns bool
579
+
580
+ Returns:
581
+ True if predicate returns True for the parsed context
582
+ """
583
+ lookback_start = max(0, start_line - lookback)
584
+ lookforward_end = min(len(lines), end_line + lookforward)
585
+
586
+ context_lines = lines[lookback_start:lookforward_end]
587
+ context = "\n".join(context_lines)
588
+
589
+ try:
590
+ tree = ast.parse(context)
591
+ return predicate(tree, lookback_start)
592
+ except SyntaxError:
593
+ pass
594
+
595
+ return False
596
+
597
+ def _is_part_of_decorator(self, lines: list[str], start_line: int, end_line: int) -> bool:
598
+ """Check if lines are part of a decorator + function definition.
599
+
600
+ A decorator pattern is @something(...) followed by def/class.
601
+ """
602
+
603
+ def has_decorators(tree: ast.Module, _lookback_start: int) -> bool:
604
+ """Check if any function or class in the tree has decorators."""
605
+ for stmt in tree.body:
606
+ if isinstance(stmt, (ast.FunctionDef, ast.ClassDef)) and stmt.decorator_list:
607
+ return True
608
+ return False
609
+
610
+ return self._check_ast_context(lines, start_line, end_line, 10, 10, has_decorators)
611
+
612
+ def _is_part_of_function_call(self, lines: list[str], start_line: int, end_line: int) -> bool:
613
+ """Check if lines are arguments inside a function/constructor call.
614
+
615
+ Detects patterns like:
616
+ obj = Constructor(
617
+ arg1=value1,
618
+ arg2=value2,
619
+ )
620
+ """
621
+
622
+ def is_single_non_function_statement(tree: ast.Module, _lookback_start: int) -> bool:
623
+ """Check if context has exactly one statement that's not a function/class def."""
624
+ return len(tree.body) == 1 and not isinstance(
625
+ tree.body[0], (ast.FunctionDef, ast.ClassDef)
626
+ )
627
+
628
+ return self._check_ast_context(
629
+ lines, start_line, end_line, 10, 10, is_single_non_function_statement
630
+ )
631
+
632
+ def _is_part_of_class_body(self, lines: list[str], start_line: int, end_line: int) -> bool:
633
+ """Check if lines are field definitions inside a class body.
634
+
635
+ Detects patterns like:
636
+ class Foo:
637
+ field1: Type1
638
+ field2: Type2
639
+ """
640
+
641
+ def is_within_class_body(tree: ast.Module, lookback_start: int) -> bool:
642
+ """Check if flagged range falls within a class body."""
643
+ for stmt in tree.body:
644
+ if not isinstance(stmt, ast.ClassDef):
645
+ continue
646
+
647
+ # Adjust line numbers: stmt.lineno is relative to context
648
+ # We need to convert back to original file line numbers
649
+ class_start_in_context = stmt.lineno
650
+ class_end_in_context = stmt.end_lineno if stmt.end_lineno else stmt.lineno
651
+
652
+ # Convert to original file line numbers (1-indexed)
653
+ class_start_original = lookback_start + class_start_in_context
654
+ class_end_original = lookback_start + class_end_in_context
655
+
656
+ # Check if the flagged range overlaps with class body
657
+ if start_line >= class_start_original and end_line <= class_end_original:
658
+ return True
659
+ return False
660
+
661
+ return self._check_ast_context(
662
+ lines,
663
+ start_line,
664
+ end_line,
665
+ AST_LOOKBACK_LINES,
666
+ AST_LOOKFORWARD_LINES,
667
+ is_within_class_body,
668
+ )