thailint 0.15.8__py3-none-any.whl → 0.17.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 (52) hide show
  1. src/cli/config.py +4 -12
  2. src/cli/linters/__init__.py +13 -3
  3. src/cli/linters/code_patterns.py +42 -38
  4. src/cli/linters/code_smells.py +8 -17
  5. src/cli/linters/documentation.py +3 -6
  6. src/cli/linters/performance.py +4 -10
  7. src/cli/linters/rust.py +177 -0
  8. src/cli/linters/shared.py +2 -7
  9. src/cli/linters/structure.py +4 -11
  10. src/cli/linters/structure_quality.py +4 -11
  11. src/cli/main.py +9 -12
  12. src/cli/utils.py +7 -16
  13. src/core/__init__.py +14 -0
  14. src/core/base.py +30 -0
  15. src/core/constants.py +1 -0
  16. src/core/linter_utils.py +42 -1
  17. src/core/rule_aliases.py +84 -0
  18. src/linter_config/rule_matcher.py +53 -8
  19. src/linters/blocking_async/__init__.py +31 -0
  20. src/linters/blocking_async/config.py +67 -0
  21. src/linters/blocking_async/linter.py +183 -0
  22. src/linters/blocking_async/rust_analyzer.py +419 -0
  23. src/linters/blocking_async/violation_builder.py +97 -0
  24. src/linters/clone_abuse/__init__.py +31 -0
  25. src/linters/clone_abuse/config.py +65 -0
  26. src/linters/clone_abuse/linter.py +183 -0
  27. src/linters/clone_abuse/rust_analyzer.py +356 -0
  28. src/linters/clone_abuse/violation_builder.py +94 -0
  29. src/linters/magic_numbers/linter.py +92 -0
  30. src/linters/magic_numbers/rust_analyzer.py +148 -0
  31. src/linters/magic_numbers/violation_builder.py +31 -0
  32. src/linters/nesting/linter.py +50 -0
  33. src/linters/nesting/rust_analyzer.py +118 -0
  34. src/linters/nesting/violation_builder.py +32 -0
  35. src/linters/print_statements/__init__.py +23 -11
  36. src/linters/print_statements/conditional_verbose_analyzer.py +200 -0
  37. src/linters/print_statements/conditional_verbose_rule.py +254 -0
  38. src/linters/print_statements/linter.py +2 -2
  39. src/linters/srp/class_analyzer.py +49 -0
  40. src/linters/srp/linter.py +22 -0
  41. src/linters/srp/rust_analyzer.py +206 -0
  42. src/linters/unwrap_abuse/__init__.py +30 -0
  43. src/linters/unwrap_abuse/config.py +59 -0
  44. src/linters/unwrap_abuse/linter.py +166 -0
  45. src/linters/unwrap_abuse/rust_analyzer.py +118 -0
  46. src/linters/unwrap_abuse/violation_builder.py +89 -0
  47. src/templates/thailint_config_template.yaml +88 -0
  48. {thailint-0.15.8.dist-info → thailint-0.17.0.dist-info}/METADATA +7 -3
  49. {thailint-0.15.8.dist-info → thailint-0.17.0.dist-info}/RECORD +52 -30
  50. {thailint-0.15.8.dist-info → thailint-0.17.0.dist-info}/WHEEL +0 -0
  51. {thailint-0.15.8.dist-info → thailint-0.17.0.dist-info}/entry_points.txt +0 -0
  52. {thailint-0.15.8.dist-info → thailint-0.17.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,31 @@
1
+ """
2
+ Purpose: Rust clone abuse detector package exports
3
+
4
+ Scope: Detect .clone() abuse patterns in Rust code and suggest safer alternatives
5
+
6
+ Overview: Package providing clone abuse detection for Rust code. Identifies .clone() calls
7
+ in loop bodies, chained .clone().clone() calls, and unnecessary clones where the source
8
+ is not used after cloning. Suggests safer alternatives including borrowing, Rc/Arc for
9
+ shared ownership, and Cow for clone-on-write patterns. Supports configuration for allowing
10
+ calls in test code, toggling individual pattern detection, and ignoring specific directories.
11
+ Uses tree-sitter for accurate AST-based detection.
12
+
13
+ Dependencies: tree-sitter-rust (optional) for AST parsing, src.core for base classes
14
+
15
+ Exports: CloneAbuseConfig, CloneAbuseRule, RustCloneAnalyzer, CloneCall
16
+
17
+ Interfaces: CloneAbuseConfig.from_dict() for YAML configuration loading
18
+
19
+ Implementation: Tree-sitter AST-based pattern detection with configurable filtering
20
+ """
21
+
22
+ from .config import CloneAbuseConfig
23
+ from .linter import CloneAbuseRule
24
+ from .rust_analyzer import CloneCall, RustCloneAnalyzer
25
+
26
+ __all__ = [
27
+ "CloneAbuseConfig",
28
+ "CloneAbuseRule",
29
+ "RustCloneAnalyzer",
30
+ "CloneCall",
31
+ ]
@@ -0,0 +1,65 @@
1
+ """
2
+ Purpose: Configuration dataclass for Rust clone abuse detector
3
+
4
+ Scope: Pattern toggles, ignore patterns, and configuration for clone abuse detection
5
+
6
+ Overview: Provides CloneAbuseConfig dataclass with toggles for controlling detection of
7
+ .clone() abuse patterns in Rust code. Supports toggling detection of clone-in-loop,
8
+ clone-chain, and unnecessary-clone patterns independently. Includes configuration for
9
+ allowing calls in test code, ignoring example and benchmark directories. Configuration
10
+ loads from YAML with sensible defaults via from_dict() class method.
11
+
12
+ Dependencies: dataclasses, typing
13
+
14
+ Exports: CloneAbuseConfig
15
+
16
+ Interfaces: CloneAbuseConfig.from_dict() for YAML configuration loading
17
+
18
+ Implementation: Dataclass with factory defaults and conservative default settings
19
+ """
20
+
21
+ from dataclasses import dataclass, field
22
+ from typing import Any
23
+
24
+
25
+ @dataclass
26
+ class CloneAbuseConfig:
27
+ """Configuration for clone abuse detection."""
28
+
29
+ enabled: bool = True
30
+
31
+ # Allow .clone() in test functions and #[cfg(test)] modules
32
+ allow_in_tests: bool = True
33
+
34
+ # Toggle detection of .clone() inside loop bodies
35
+ detect_clone_in_loop: bool = True
36
+
37
+ # Toggle detection of chained .clone().clone() calls
38
+ detect_clone_chain: bool = True
39
+
40
+ # Toggle detection of unnecessary clones (clone before move)
41
+ detect_unnecessary_clone: bool = True
42
+
43
+ # File path patterns to ignore (e.g., examples/, benches/)
44
+ ignore: list[str] = field(default_factory=lambda: ["examples/", "benches/", "tests/"])
45
+
46
+ @classmethod
47
+ def from_dict(cls, config: dict[str, Any], language: str | None = None) -> "CloneAbuseConfig":
48
+ """Load configuration from dictionary.
49
+
50
+ Args:
51
+ config: Configuration dictionary from YAML
52
+ language: Language parameter (reserved for future use)
53
+
54
+ Returns:
55
+ Configured CloneAbuseConfig instance
56
+ """
57
+ _ = language
58
+ return cls(
59
+ enabled=config.get("enabled", True),
60
+ allow_in_tests=config.get("allow_in_tests", True),
61
+ detect_clone_in_loop=config.get("detect_clone_in_loop", True),
62
+ detect_clone_chain=config.get("detect_clone_chain", True),
63
+ detect_unnecessary_clone=config.get("detect_unnecessary_clone", True),
64
+ ignore=config.get("ignore", ["examples/", "benches/", "tests/"]),
65
+ )
@@ -0,0 +1,183 @@
1
+ """
2
+ Purpose: Main linter rule for detecting clone abuse in Rust code
3
+
4
+ Scope: Entry point for clone abuse detection implementing BaseLintRule interface
5
+
6
+ Overview: Provides CloneAbuseRule class that implements the BaseLintRule interface for
7
+ detecting .clone() abuse patterns in Rust code. Validates that files are Rust with
8
+ content, loads configuration, checks ignored paths, and delegates analysis to
9
+ RustCloneAnalyzer. Filters detected calls based on configuration (allow_in_tests,
10
+ pattern toggles, ignored paths) and converts remaining calls to Violation objects
11
+ via the violation builder. Supports disabling via configuration.
12
+
13
+ Dependencies: BaseLintRule, RustCloneAnalyzer, CloneAbuseConfig, violation_builder
14
+
15
+ Exports: CloneAbuseRule
16
+
17
+ Interfaces: check(context: BaseLintContext) -> list[Violation]
18
+
19
+ Implementation: Single-file analysis with config-driven filtering and tree-sitter-based detection
20
+ """
21
+
22
+ from src.core.base import BaseLintContext, BaseLintRule
23
+ from src.core.linter_utils import (
24
+ has_file_content,
25
+ is_ignored_path,
26
+ load_linter_config,
27
+ resolve_file_path,
28
+ )
29
+ from src.core.types import Violation
30
+
31
+ from .config import CloneAbuseConfig
32
+ from .rust_analyzer import CloneCall, RustCloneAnalyzer
33
+ from .violation_builder import (
34
+ build_clone_chain_violation,
35
+ build_clone_in_loop_violation,
36
+ build_unnecessary_clone_violation,
37
+ )
38
+
39
+ _PATTERN_BUILDERS = {
40
+ "clone-in-loop": build_clone_in_loop_violation,
41
+ "clone-chain": build_clone_chain_violation,
42
+ "unnecessary-clone": build_unnecessary_clone_violation,
43
+ }
44
+
45
+ _PATTERN_CONFIG_KEYS = {
46
+ "clone-in-loop": "detect_clone_in_loop",
47
+ "clone-chain": "detect_clone_chain",
48
+ "unnecessary-clone": "detect_unnecessary_clone",
49
+ }
50
+
51
+
52
+ class CloneAbuseRule(BaseLintRule):
53
+ """Detects clone abuse patterns in Rust code."""
54
+
55
+ def __init__(self, config: CloneAbuseConfig | None = None) -> None:
56
+ """Initialize clone abuse rule.
57
+
58
+ Args:
59
+ config: Optional configuration override for testing
60
+ """
61
+ self._config_override = config
62
+ self._analyzer = RustCloneAnalyzer()
63
+
64
+ @property
65
+ def rule_id(self) -> str:
66
+ """Unique identifier for this rule."""
67
+ return "clone-abuse"
68
+
69
+ @property
70
+ def rule_name(self) -> str:
71
+ """Human-readable name for this rule."""
72
+ return "Rust Clone Abuse"
73
+
74
+ @property
75
+ def description(self) -> str:
76
+ """Description of what this rule checks."""
77
+ return (
78
+ "Detects .clone() abuse patterns in Rust code including clone in loops, "
79
+ "chained clones, and unnecessary clones. Suggests safer alternatives like "
80
+ "borrowing, Rc/Arc, or Cow patterns."
81
+ )
82
+
83
+ def check(self, context: BaseLintContext) -> list[Violation]:
84
+ """Check for clone abuse violations in a Rust file.
85
+
86
+ Args:
87
+ context: Lint context with file content and metadata
88
+
89
+ Returns:
90
+ List of violations found
91
+ """
92
+ config = self._get_config(context)
93
+ if not self._should_analyze(context, config):
94
+ return []
95
+
96
+ file_path = resolve_file_path(context)
97
+ calls = self._analyzer.find_clone_calls(context.file_content or "")
98
+ return self._build_violations(calls, config, file_path)
99
+
100
+ def _should_analyze(self, context: BaseLintContext, config: CloneAbuseConfig) -> bool:
101
+ """Determine if the file should be analyzed.
102
+
103
+ Args:
104
+ context: Lint context to check
105
+ config: Clone abuse configuration
106
+
107
+ Returns:
108
+ True if the file should be analyzed
109
+ """
110
+ if context.language != "rust":
111
+ return False
112
+ if not has_file_content(context):
113
+ return False
114
+ if not config.enabled:
115
+ return False
116
+ return not is_ignored_path(resolve_file_path(context), config.ignore)
117
+
118
+ def _get_config(self, context: BaseLintContext) -> CloneAbuseConfig:
119
+ """Load configuration from override or context metadata.
120
+
121
+ Args:
122
+ context: Lint context with metadata
123
+
124
+ Returns:
125
+ CloneAbuseConfig instance
126
+ """
127
+ if self._config_override is not None:
128
+ return self._config_override
129
+ return load_linter_config(context, "clone-abuse", CloneAbuseConfig)
130
+
131
+ def _build_violations(
132
+ self,
133
+ calls: list[CloneCall],
134
+ config: CloneAbuseConfig,
135
+ file_path: str,
136
+ ) -> list[Violation]:
137
+ """Convert clone calls to violations with config filtering.
138
+
139
+ Args:
140
+ calls: Detected clone calls from analyzer
141
+ config: Configuration for filtering
142
+ file_path: Path of the analyzed file
143
+
144
+ Returns:
145
+ List of filtered violations
146
+ """
147
+ return [
148
+ _build_violation_for_call(call, file_path)
149
+ for call in calls
150
+ if not _should_skip_call(call, config)
151
+ ]
152
+
153
+
154
+ def _should_skip_call(call: CloneCall, config: CloneAbuseConfig) -> bool:
155
+ """Determine if a clone call should be skipped based on config.
156
+
157
+ Args:
158
+ call: Detected clone call
159
+ config: Configuration for filtering
160
+
161
+ Returns:
162
+ True if the call should be skipped
163
+ """
164
+ if call.is_in_test and config.allow_in_tests:
165
+ return True
166
+ config_key = _PATTERN_CONFIG_KEYS.get(call.pattern)
167
+ if config_key and not getattr(config, config_key):
168
+ return True
169
+ return False
170
+
171
+
172
+ def _build_violation_for_call(call: CloneCall, file_path: str) -> Violation:
173
+ """Build a violation for a specific clone call.
174
+
175
+ Args:
176
+ call: Detected clone call with pattern info
177
+ file_path: Path of the analyzed file
178
+
179
+ Returns:
180
+ Violation instance
181
+ """
182
+ builder = _PATTERN_BUILDERS.get(call.pattern, build_clone_in_loop_violation)
183
+ return builder(file_path, call.line, call.column, call.context)
@@ -0,0 +1,356 @@
1
+ """
2
+ Purpose: Analyzer for detecting clone abuse patterns in Rust code using tree-sitter AST
3
+
4
+ Scope: Pattern detection for .clone() method calls including loop, chain, and unnecessary patterns
5
+
6
+ Overview: Provides RustCloneAnalyzer that extends RustBaseAnalyzer to detect .clone() abuse
7
+ patterns in Rust code. Detects three patterns: clone-in-loop (clone calls inside for,
8
+ while, or loop bodies), clone-chain (chained .clone().clone() calls), and unnecessary-clone
9
+ (clone in let binding where the source identifier is not used again in the enclosing block).
10
+ Uses tree-sitter AST to find call_expression nodes with field_identifier matching "clone"
11
+ and classifies each call by walking the AST structure. Returns structured CloneCall dataclass
12
+ instances with location, pattern type, test context, and surrounding code for violation reporting.
13
+
14
+ Dependencies: src.analyzers.rust_base for tree-sitter parsing and traversal
15
+
16
+ Exports: RustCloneAnalyzer, CloneCall
17
+
18
+ Interfaces: find_clone_calls(code: str) -> list[CloneCall]
19
+
20
+ Implementation: Recursive AST traversal with pattern classification using parent-chain walking
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from dataclasses import dataclass
26
+ from typing import TYPE_CHECKING
27
+
28
+ from src.analyzers.rust_base import RustBaseAnalyzer
29
+ from src.core.linter_utils import get_line_context
30
+
31
+ if TYPE_CHECKING:
32
+ from tree_sitter import Node
33
+
34
+ _LOOP_NODE_TYPES = frozenset({"for_expression", "while_expression", "loop_expression"})
35
+
36
+
37
+ @dataclass
38
+ class CloneCall:
39
+ """Represents a detected clone call with its abuse pattern."""
40
+
41
+ line: int
42
+ column: int
43
+ pattern: str # "clone-in-loop", "clone-chain", "unnecessary-clone"
44
+ is_in_test: bool
45
+ context: str # Surrounding code snippet
46
+
47
+
48
+ class RustCloneAnalyzer(RustBaseAnalyzer):
49
+ """Analyzer for detecting clone abuse patterns in Rust code."""
50
+
51
+ def find_clone_calls(self, code: str) -> list[CloneCall]:
52
+ """Find all abusive .clone() calls in code.
53
+
54
+ Args:
55
+ code: Rust source code to analyze
56
+
57
+ Returns:
58
+ List of detected clone calls with pattern classification
59
+ """
60
+ if not self.tree_sitter_available:
61
+ return []
62
+
63
+ root = self.parse_rust(code)
64
+ if root is None:
65
+ return []
66
+
67
+ calls: list[CloneCall] = []
68
+ self._find_clone_recursive(root, code, calls)
69
+ return calls
70
+
71
+ def _find_clone_recursive(self, node: Node, code: str, calls: list[CloneCall]) -> None:
72
+ """Recursively find abusive clone calls in AST.
73
+
74
+ Args:
75
+ node: Current tree-sitter node to inspect
76
+ code: Original source code for context extraction
77
+ calls: Accumulator list for detected calls
78
+ """
79
+ if node.type == "call_expression":
80
+ method_name = self._get_method_name(node)
81
+ if method_name == "clone":
82
+ pattern = self._classify_clone(node, code)
83
+ if pattern is not None:
84
+ calls.append(
85
+ CloneCall(
86
+ line=node.start_point[0] + 1,
87
+ column=node.start_point[1],
88
+ pattern=pattern,
89
+ is_in_test=self.is_inside_test(node),
90
+ context=get_line_context(code, node.start_point[0]),
91
+ )
92
+ )
93
+
94
+ for child in node.children:
95
+ self._find_clone_recursive(child, code, calls)
96
+
97
+ def _get_method_name(self, call_node: Node) -> str:
98
+ """Extract method name from a call expression.
99
+
100
+ Args:
101
+ call_node: A call_expression node
102
+
103
+ Returns:
104
+ Method name string, or empty string if not a method call
105
+ """
106
+ for child in call_node.children:
107
+ if child.type == "field_expression":
108
+ return self._extract_field_identifier(child)
109
+ return ""
110
+
111
+ def _extract_field_identifier(self, field_expr: Node) -> str:
112
+ """Extract the field identifier from a field expression.
113
+
114
+ Args:
115
+ field_expr: A field_expression node
116
+
117
+ Returns:
118
+ The field identifier text (e.g., "clone")
119
+ """
120
+ for subchild in field_expr.children:
121
+ if subchild.type == "field_identifier":
122
+ return self.extract_node_text(subchild)
123
+ return ""
124
+
125
+ def _classify_clone(self, node: Node, code: str) -> str | None:
126
+ """Classify a clone call into an abuse pattern.
127
+
128
+ Checks patterns in priority order: chain, loop, unnecessary.
129
+ Returns None if the clone does not match any abuse pattern.
130
+
131
+ Args:
132
+ node: The call_expression node for the .clone() call
133
+ code: Original source code
134
+
135
+ Returns:
136
+ Pattern string or None if not abusive
137
+ """
138
+ _ = code
139
+ if self._is_chained_clone(node):
140
+ return "clone-chain"
141
+ if self._is_inside_loop(node):
142
+ return "clone-in-loop"
143
+ if self._is_unnecessary_clone(node):
144
+ return "unnecessary-clone"
145
+ return None
146
+
147
+ def _is_inside_loop(self, node: Node) -> bool:
148
+ """Check if node is inside a loop body.
149
+
150
+ Walks the parent chain looking for loop node types.
151
+
152
+ Args:
153
+ node: Node to check
154
+
155
+ Returns:
156
+ True if inside a for, while, or loop expression
157
+ """
158
+ current: Node | None = node.parent
159
+ while current is not None:
160
+ if current.type in _LOOP_NODE_TYPES:
161
+ return True
162
+ current = current.parent
163
+ return False
164
+
165
+ def _is_chained_clone(self, node: Node) -> bool:
166
+ """Check if this clone's receiver is itself a .clone() call.
167
+
168
+ Detects patterns like data.clone().clone() where the outer clone's
169
+ receiver (via field_expression) is a call_expression with method "clone".
170
+
171
+ Args:
172
+ node: The call_expression node for this .clone() call
173
+
174
+ Returns:
175
+ True if this clone is chained on another clone
176
+ """
177
+ field_expr = _get_field_expression(node)
178
+ if field_expr is None:
179
+ return False
180
+ receiver = _get_receiver_node(field_expr)
181
+ if receiver is None or receiver.type != "call_expression":
182
+ return False
183
+ return self._get_method_name(receiver) == "clone"
184
+
185
+ def _is_unnecessary_clone(self, node: Node) -> bool:
186
+ """Check if clone is unnecessary (source not used after cloning).
187
+
188
+ Only flags when ALL conditions are met:
189
+ - The clone is in a let declaration: let x = y.clone();
190
+ - The receiver y is a simple identifier (not self.field, not foo.bar())
191
+ - y does not appear in any subsequent statement in the same block
192
+
193
+ Args:
194
+ node: The call_expression node for this .clone() call
195
+
196
+ Returns:
197
+ True if the clone appears unnecessary
198
+ """
199
+ let_node = _find_parent_let_declaration(node)
200
+ if let_node is None:
201
+ return False
202
+
203
+ identifier = self._get_clone_receiver_identifier(node)
204
+ if identifier is None:
205
+ return False
206
+
207
+ block_node = _find_parent_block(let_node)
208
+ if block_node is None:
209
+ return False
210
+
211
+ return not _identifier_used_after(identifier, let_node, block_node)
212
+
213
+ def _get_clone_receiver_identifier(self, node: Node) -> str | None:
214
+ """Extract the simple identifier being cloned.
215
+
216
+ Returns None for complex expressions like self.field or foo.bar().
217
+
218
+ Args:
219
+ node: The call_expression node for this .clone() call
220
+
221
+ Returns:
222
+ Simple identifier string, or None for complex receivers
223
+ """
224
+ field_expr = _get_field_expression(node)
225
+ if field_expr is None:
226
+ return None
227
+ receiver = _get_receiver_node(field_expr)
228
+ if receiver is None:
229
+ return None
230
+ if receiver.type != "identifier":
231
+ return None
232
+ return self.extract_node_text(receiver)
233
+
234
+
235
+ def _get_field_expression(call_node: Node) -> Node | None:
236
+ """Get the field_expression child of a call_expression.
237
+
238
+ Args:
239
+ call_node: A call_expression node
240
+
241
+ Returns:
242
+ The field_expression child, or None
243
+ """
244
+ for child in call_node.children:
245
+ if child.type == "field_expression":
246
+ return child
247
+ return None
248
+
249
+
250
+ def _get_receiver_node(field_expr: Node) -> Node | None:
251
+ """Get the receiver (first child) of a field_expression.
252
+
253
+ In Rust tree-sitter, field_expression children are:
254
+ [receiver, ".", field_identifier]
255
+
256
+ Args:
257
+ field_expr: A field_expression node
258
+
259
+ Returns:
260
+ The receiver node, or None
261
+ """
262
+ children = field_expr.children
263
+ if children:
264
+ return children[0]
265
+ return None
266
+
267
+
268
+ def _find_parent_let_declaration(node: Node) -> Node | None:
269
+ """Find the enclosing let_declaration for a node.
270
+
271
+ Args:
272
+ node: Starting node to search from
273
+
274
+ Returns:
275
+ The let_declaration node, or None if not inside one
276
+ """
277
+ current: Node | None = node.parent
278
+ while current is not None:
279
+ if current.type == "let_declaration":
280
+ return current
281
+ if current.type in ("block", "function_item"):
282
+ return None
283
+ current = current.parent
284
+ return None
285
+
286
+
287
+ def _find_parent_block(node: Node) -> Node | None:
288
+ """Find the enclosing block for a node.
289
+
290
+ Args:
291
+ node: Starting node to search from
292
+
293
+ Returns:
294
+ The block node, or None
295
+ """
296
+ current: Node | None = node.parent
297
+ while current is not None:
298
+ if current.type == "block":
299
+ return current
300
+ current = current.parent
301
+ return None
302
+
303
+
304
+ def _identifier_used_after(identifier: str, let_node: Node, block_node: Node) -> bool:
305
+ """Check if identifier appears in statements after let_node in the block.
306
+
307
+ Scans subsequent siblings of let_node within block_node for any reference
308
+ to the given identifier.
309
+
310
+ Args:
311
+ identifier: The variable name to search for
312
+ let_node: The let_declaration node
313
+ block_node: The enclosing block node
314
+
315
+ Returns:
316
+ True if identifier is referenced after the let_node
317
+ """
318
+ found_let = False
319
+ for child in block_node.children:
320
+ if child.id == let_node.id:
321
+ found_let = True
322
+ continue
323
+ if found_let and _node_contains_identifier(child, identifier):
324
+ return True
325
+ return False
326
+
327
+
328
+ def _node_contains_identifier(node: Node, identifier: str) -> bool:
329
+ """Recursively check if a node or its descendants contain an identifier reference.
330
+
331
+ Args:
332
+ node: Node to search in
333
+ identifier: Identifier name to look for
334
+
335
+ Returns:
336
+ True if the identifier is found
337
+ """
338
+ if _is_matching_identifier(node, identifier):
339
+ return True
340
+ return any(_node_contains_identifier(child, identifier) for child in node.children)
341
+
342
+
343
+ def _is_matching_identifier(node: Node, identifier: str) -> bool:
344
+ """Check if a node is an identifier matching the given name.
345
+
346
+ Args:
347
+ node: Node to check
348
+ identifier: Expected identifier name
349
+
350
+ Returns:
351
+ True if node is an identifier with the given name
352
+ """
353
+ if node.type != "identifier":
354
+ return False
355
+ text = node.text
356
+ return text is not None and text.decode() == identifier