code-prune 1.0.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.
code_prune/scanner.py ADDED
@@ -0,0 +1,800 @@
1
+ """Dead-code scanner: unreachable, unused, and obsolete code detectors.
2
+
3
+ Five tree-sitter grammars are supported: Python, JavaScript, TypeScript, Java,
4
+ and Gosu. Detection is intentionally heuristic/syntactic rather than a full
5
+ scope- or call-graph-aware analysis, matching the spirit of a fast, best-effort
6
+ static-analysis CLI tool.
7
+
8
+ Author: Ron Webb
9
+ Since: 1.0.0
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ import re
16
+ from collections import Counter
17
+ from collections.abc import Generator, Iterable
18
+ from dataclasses import dataclass, field
19
+ from pathlib import Path
20
+
21
+ from tree_sitter import Node, Tree # type: ignore[import-untyped]
22
+
23
+ from .parser import parse
24
+
25
+ _logger = logging.getLogger(__name__)
26
+
27
+ # --------------------------------------------------------------------------
28
+ # Finding categories
29
+ # --------------------------------------------------------------------------
30
+
31
+ CATEGORY_UNREACHABLE = "unreachable"
32
+ CATEGORY_UNUSED_IMPORT = "unused-import"
33
+ CATEGORY_UNUSED_LOCAL = "unused-local"
34
+ CATEGORY_UNUSED_SYMBOL = "unused-symbol"
35
+ CATEGORY_OBSOLETE = "obsolete"
36
+ CATEGORY_COMMENTED_OUT = "commented-out"
37
+
38
+ #: Categories a user may toggle via ``--categories``. ``unused`` expands to
39
+ #: both :data:`CATEGORY_UNUSED_IMPORT`/:data:`CATEGORY_UNUSED_LOCAL` (per-file)
40
+ #: and :data:`CATEGORY_UNUSED_SYMBOL` (cross-file).
41
+ CLI_CATEGORIES = ("unreachable", "unused", "obsolete", "commented-out")
42
+
43
+ # --------------------------------------------------------------------------
44
+ # Per-language grammar node-type tables (verified empirically against each
45
+ # tree-sitter grammar before being hard-coded here).
46
+ # --------------------------------------------------------------------------
47
+
48
+ EXTENSION_TO_LANGUAGE: dict[str, str] = {
49
+ ".py": "Python",
50
+ ".pyi": "Python",
51
+ ".js": "JavaScript",
52
+ ".mjs": "JavaScript",
53
+ ".cjs": "JavaScript",
54
+ ".ts": "TypeScript",
55
+ ".tsx": "TypeScript",
56
+ ".java": "Java",
57
+ ".gs": "Gosu",
58
+ ".gsx": "Gosu",
59
+ }
60
+
61
+ #: Function/method definition node types, per language.
62
+ FUNCTION_NODE_TYPES: dict[str, frozenset[str]] = {
63
+ "Python": frozenset({"function_definition"}),
64
+ "JavaScript": frozenset(
65
+ {
66
+ "function_declaration",
67
+ "function_expression",
68
+ "arrow_function",
69
+ "method_definition",
70
+ }
71
+ ),
72
+ "TypeScript": frozenset(
73
+ {
74
+ "function_declaration",
75
+ "function_expression",
76
+ "arrow_function",
77
+ "method_definition",
78
+ }
79
+ ),
80
+ "Java": frozenset({"method_declaration", "constructor_declaration"}),
81
+ "Gosu": frozenset({"function_declaration", "constructor_declaration"}),
82
+ }
83
+
84
+ #: Class/interface definition node types, per language.
85
+ CLASS_NODE_TYPES: dict[str, frozenset[str]] = {
86
+ "Python": frozenset({"class_definition"}),
87
+ "JavaScript": frozenset({"class_declaration"}),
88
+ "TypeScript": frozenset({"class_declaration", "interface_declaration"}),
89
+ "Java": frozenset(
90
+ {"class_declaration", "interface_declaration", "enum_declaration"}
91
+ ),
92
+ "Gosu": frozenset({"class_declaration"}),
93
+ }
94
+
95
+ #: Import/uses-clause node types, per language.
96
+ IMPORT_NODE_TYPES: dict[str, frozenset[str]] = {
97
+ "Python": frozenset({"import_statement", "import_from_statement"}),
98
+ "JavaScript": frozenset({"import_statement"}),
99
+ "TypeScript": frozenset({"import_statement"}),
100
+ "Java": frozenset({"import_declaration"}),
101
+ "Gosu": frozenset({"uses_declaration"}),
102
+ }
103
+
104
+ #: Local-variable declaration/assignment node types, per language.
105
+ LOCAL_DECL_NODE_TYPES: dict[str, frozenset[str]] = {
106
+ "Python": frozenset({"assignment"}),
107
+ "JavaScript": frozenset({"variable_declarator"}),
108
+ "TypeScript": frozenset({"variable_declarator"}),
109
+ "Java": frozenset({"variable_declarator"}),
110
+ "Gosu": frozenset({"local_variable_declaration"}),
111
+ }
112
+
113
+ #: Statements that terminate control flow within a block, per language.
114
+ TERMINATING_STATEMENT_TYPES: dict[str, frozenset[str]] = {
115
+ "Python": frozenset(
116
+ {"return_statement", "raise_statement", "break_statement", "continue_statement"}
117
+ ),
118
+ "JavaScript": frozenset(
119
+ {"return_statement", "throw_statement", "break_statement", "continue_statement"}
120
+ ),
121
+ "TypeScript": frozenset(
122
+ {"return_statement", "throw_statement", "break_statement", "continue_statement"}
123
+ ),
124
+ "Java": frozenset(
125
+ {"return_statement", "throw_statement", "break_statement", "continue_statement"}
126
+ ),
127
+ "Gosu": frozenset(
128
+ {"return_statement", "throw_statement", "break_statement", "continue_statement"}
129
+ ),
130
+ }
131
+
132
+ #: Node types whose direct named children form a sequential statement list.
133
+ BLOCK_NODE_TYPES: dict[str, frozenset[str]] = {
134
+ "Python": frozenset({"block"}),
135
+ "JavaScript": frozenset({"statement_block"}),
136
+ "TypeScript": frozenset({"statement_block"}),
137
+ "Java": frozenset({"block"}),
138
+ "Gosu": frozenset({"statement_block"}),
139
+ }
140
+
141
+ #: Comment node types, per language.
142
+ COMMENT_NODE_TYPES: dict[str, frozenset[str]] = {
143
+ "Python": frozenset({"comment"}),
144
+ "JavaScript": frozenset({"comment"}),
145
+ "TypeScript": frozenset({"comment"}),
146
+ "Java": frozenset({"line_comment", "block_comment"}),
147
+ "Gosu": frozenset({"line_comment", "block_comment"}),
148
+ }
149
+
150
+ #: Decorator/annotation node types consulted for deprecation markers.
151
+ DECORATOR_NODE_TYPES: dict[str, frozenset[str]] = {
152
+ "Python": frozenset({"decorator"}),
153
+ "JavaScript": frozenset(),
154
+ "TypeScript": frozenset({"decorator"}),
155
+ "Java": frozenset({"marker_annotation", "annotation"}),
156
+ "Gosu": frozenset({"annotation"}),
157
+ }
158
+
159
+ #: Identifier-like node types counted as a "usage" of a name.
160
+ USAGE_NODE_TYPES: dict[str, frozenset[str]] = {
161
+ "Python": frozenset({"identifier"}),
162
+ "JavaScript": frozenset(
163
+ {"identifier", "property_identifier", "shorthand_property_identifier"}
164
+ ),
165
+ "TypeScript": frozenset(
166
+ {
167
+ "identifier",
168
+ "property_identifier",
169
+ "shorthand_property_identifier",
170
+ "type_identifier",
171
+ }
172
+ ),
173
+ "Java": frozenset({"identifier", "type_identifier"}),
174
+ "Gosu": frozenset({"identifier"}),
175
+ }
176
+
177
+ #: Regex matching a condition expression that is always false (after
178
+ #: optionally stripping surrounding parentheses added by some grammars).
179
+ _FALSY_CONDITION_RE = re.compile(r"^\(*\s*(False|false|0)\s*\)*$")
180
+
181
+ #: Regex matching obsolete markers inside comment text.
182
+ _OBSOLETE_COMMENT_RE = re.compile(r"\b(TODO|FIXME|XXX|deprecated)\b", re.IGNORECASE)
183
+
184
+ #: Regex fragments that suggest a comment line contains commented-out code.
185
+ _CODE_LIKE_RE = re.compile(
186
+ r"^\s*(def\s+\w+\(|class\s+\w+|function\s*\w*\(|public\s|private\s|protected\s|"
187
+ r"static\s|if\s*\(|for\s*\(|while\s*\(|return\b|import\s|from\s+\S+\s+import|"
188
+ r"var\s+\w+\s*=|let\s+\w+\s*=|const\s+\w+\s*=|package\s|uses\s)"
189
+ )
190
+ _ASSIGNMENT_RE = re.compile(r"[^=!<>+\-*/]=[^=]")
191
+
192
+
193
+ @dataclass(frozen=True)
194
+ class Finding:
195
+ """A single detected issue in source code.
196
+
197
+ Author: Ron Webb
198
+ Since: 1.0.0
199
+ """
200
+
201
+ category: str
202
+ file_path: Path
203
+ line: int
204
+ column: int
205
+ name: str
206
+ message: str
207
+
208
+
209
+ @dataclass(frozen=True)
210
+ class Definition:
211
+ """A function/class/method definition discovered in one file.
212
+
213
+ Author: Ron Webb
214
+ Since: 1.0.0
215
+ """
216
+
217
+ name: str
218
+ kind: str
219
+ file_path: Path
220
+ line: int
221
+ column: int
222
+
223
+
224
+ @dataclass
225
+ class ScanResult:
226
+ """Aggregated results of scanning a single file.
227
+
228
+ ``usages`` holds every non-binding identifier-like name found in the file;
229
+ callers feed it into a project-wide tally to resolve cross-file
230
+ :data:`CATEGORY_UNUSED_SYMBOL` findings once every file has been scanned.
231
+
232
+ Author: Ron Webb
233
+ Since: 1.0.0
234
+ """
235
+
236
+ findings: list[Finding] = field(default_factory=list)
237
+ definitions: list[Definition] = field(default_factory=list)
238
+ usages: list[str] = field(default_factory=list)
239
+
240
+
241
+ def _decode(source_bytes: bytes, node: Node) -> str:
242
+ """Return the UTF-8 decoded text spanned by *node*.
243
+
244
+ Author: Ron Webb
245
+ Since: 1.0.0
246
+ """
247
+ return source_bytes[node.start_byte : node.end_byte].decode(
248
+ "utf-8", errors="replace"
249
+ )
250
+
251
+
252
+ def _iter_nodes(node: Node) -> Generator[Node, None, None]:
253
+ """Recursively yield *node* and every descendant.
254
+
255
+ Author: Ron Webb
256
+ Since: 1.0.0
257
+ """
258
+ yield node
259
+ for child in node.children:
260
+ yield from _iter_nodes(child)
261
+
262
+
263
+ # --------------------------------------------------------------------------
264
+ # Unreachable code
265
+ # --------------------------------------------------------------------------
266
+
267
+
268
+ def _is_dead_condition(source_bytes: bytes, condition: Node | None) -> bool:
269
+ """Return ``True`` when *condition* always evaluates to a falsy constant.
270
+
271
+ Author: Ron Webb
272
+ Since: 1.0.0
273
+ """
274
+ if condition is None:
275
+ return False
276
+ return bool(_FALSY_CONDITION_RE.match(_decode(source_bytes, condition).strip()))
277
+
278
+
279
+ def detect_unreachable(
280
+ tree: Tree, source_bytes: bytes, language: str, file_path: Path
281
+ ) -> list[Finding]:
282
+ """Detect statements that can never execute.
283
+
284
+ Two patterns are recognised: statements following a terminating statement
285
+ (``return``/``raise``/``throw``/``break``/``continue``) within the same
286
+ block, and the body of an ``if`` whose condition is a constant falsy
287
+ literal (``False``/``false``/``0``).
288
+
289
+ Author: Ron Webb
290
+ Since: 1.0.0
291
+ """
292
+ block_types = BLOCK_NODE_TYPES.get(language, frozenset())
293
+ terminating_types = TERMINATING_STATEMENT_TYPES.get(language, frozenset())
294
+ findings: list[Finding] = []
295
+
296
+ for node in _iter_nodes(tree.root_node):
297
+ if node.type in block_types:
298
+ findings.extend(
299
+ _unreachable_after_terminator(node, terminating_types, file_path)
300
+ )
301
+ elif node.type == "if_statement":
302
+ findings.extend(_unreachable_dead_branch(node, source_bytes, file_path))
303
+
304
+ return findings
305
+
306
+
307
+ def _unreachable_after_terminator(
308
+ block: Node, terminating_types: frozenset[str], file_path: Path
309
+ ) -> list[Finding]:
310
+ """Flag statements following a terminator within *block*.
311
+
312
+ Author: Ron Webb
313
+ Since: 1.0.0
314
+ """
315
+ findings: list[Finding] = []
316
+ statements = block.named_children
317
+ terminator_index: int | None = None
318
+ for index, statement in enumerate(statements):
319
+ if terminator_index is not None:
320
+ row, col = statement.start_point
321
+ findings.append(
322
+ Finding(
323
+ category=CATEGORY_UNREACHABLE,
324
+ file_path=file_path,
325
+ line=row + 1,
326
+ column=col,
327
+ name=statement.type,
328
+ message="Unreachable code after a terminating statement.",
329
+ )
330
+ )
331
+ elif statement.type in terminating_types:
332
+ terminator_index = index
333
+ return findings
334
+
335
+
336
+ def _unreachable_dead_branch(
337
+ if_node: Node, source_bytes: bytes, file_path: Path
338
+ ) -> list[Finding]:
339
+ """Flag the body of an ``if`` whose condition is always falsy.
340
+
341
+ Author: Ron Webb
342
+ Since: 1.0.0
343
+ """
344
+ condition = if_node.child_by_field_name("condition")
345
+ consequence = if_node.child_by_field_name("consequence")
346
+ if consequence is None or not _is_dead_condition(source_bytes, condition):
347
+ return []
348
+ row, col = consequence.start_point
349
+ return [
350
+ Finding(
351
+ category=CATEGORY_UNREACHABLE,
352
+ file_path=file_path,
353
+ line=row + 1,
354
+ column=col,
355
+ name=if_node.type,
356
+ message="Unreachable branch: condition is always false.",
357
+ )
358
+ ]
359
+
360
+
361
+ # --------------------------------------------------------------------------
362
+ # Bindings (imports / locals / definitions) and usages
363
+ # --------------------------------------------------------------------------
364
+
365
+
366
+ @dataclass
367
+ class _Bindings:
368
+ """Intermediate state collected while walking a file once.
369
+
370
+ Author: Ron Webb
371
+ Since: 1.0.0
372
+ """
373
+
374
+ import_bindings: list[tuple[Node, str]] = field(default_factory=list)
375
+ local_bindings: list[tuple[Node, str]] = field(default_factory=list)
376
+ definitions: list[Definition] = field(default_factory=list)
377
+ excluded_positions: set[int] = field(default_factory=set)
378
+
379
+
380
+ def _import_bound_name_nodes(import_node: Node, language: str) -> list[Node]:
381
+ """Return the identifier nodes that *import_node* binds locally.
382
+
383
+ Author: Ron Webb
384
+ Since: 1.0.0
385
+ """
386
+ if language == "Python":
387
+ return _python_import_bound_names(import_node)
388
+ if language in ("JavaScript", "TypeScript"):
389
+ return [
390
+ n
391
+ for n in _iter_nodes(import_node)
392
+ if n.type in ("identifier", "property_identifier") and n != import_node
393
+ ]
394
+ # Java import_declaration / Gosu uses_declaration: bind the last identifier
395
+ # in the dotted/scoped name (the simple class name); skip wildcard imports.
396
+ identifiers = [n for n in import_node.named_children if n.type == "identifier"]
397
+ tail = _last_identifier(import_node)
398
+ return [tail] if tail is not None else identifiers
399
+
400
+
401
+ def _python_import_bound_names(import_node: Node) -> list[Node]:
402
+ """Return bound-name identifier nodes for a Python import statement.
403
+
404
+ ``import a.b.c`` binds ``a``; ``import a.b.c as x`` and
405
+ ``from m import a as x`` bind ``x``; ``from m import a`` binds ``a``.
406
+ The ``from``-clause module path itself (``module_name`` field) is never a
407
+ binding and is excluded.
408
+
409
+ Author: Ron Webb
410
+ Since: 1.0.0
411
+ """
412
+ module_name = import_node.child_by_field_name("module_name")
413
+ bound: list[Node] = []
414
+ for child in import_node.named_children:
415
+ if child == module_name:
416
+ continue
417
+ if child.type == "aliased_import":
418
+ alias = child.named_children[-1] if child.named_children else None
419
+ if alias is not None:
420
+ bound.append(alias)
421
+ elif child.type == "dotted_name":
422
+ is_from_import = import_node.type == "import_from_statement"
423
+ target = (
424
+ child.named_children[-1] if is_from_import else child.named_children[0]
425
+ )
426
+ if target is not None:
427
+ bound.append(target)
428
+ return bound
429
+
430
+
431
+ def _last_identifier(node: Node) -> Node | None:
432
+ """Return the last ``identifier`` descendant of *node*, if any.
433
+
434
+ Author: Ron Webb
435
+ Since: 1.0.0
436
+ """
437
+ identifiers = [n for n in _iter_nodes(node) if n.type == "identifier"]
438
+ return identifiers[-1] if identifiers else None
439
+
440
+
441
+ def _local_bound_name_node(decl_node: Node, language: str) -> Node | None:
442
+ """Return the identifier node bound by a local-variable declaration.
443
+
444
+ Author: Ron Webb
445
+ Since: 1.0.0
446
+ """
447
+ if language == "Gosu":
448
+ return next(
449
+ (c for c in decl_node.named_children if c.type == "identifier"), None
450
+ )
451
+ name = decl_node.child_by_field_name("name")
452
+ if name is not None and name.type in ("identifier",):
453
+ return name
454
+ left = decl_node.child_by_field_name("left")
455
+ if left is not None and left.type == "identifier":
456
+ return left
457
+ return None
458
+
459
+
460
+ def _collect_bindings(
461
+ tree: Tree, source_bytes: bytes, language: str, file_path: Path
462
+ ) -> _Bindings:
463
+ """Walk *tree* once, collecting import/local/definition bindings.
464
+
465
+ Author: Ron Webb
466
+ Since: 1.0.0
467
+ """
468
+ bindings = _Bindings()
469
+ import_types = IMPORT_NODE_TYPES.get(language, frozenset())
470
+ local_types = LOCAL_DECL_NODE_TYPES.get(language, frozenset())
471
+ function_types = FUNCTION_NODE_TYPES.get(language, frozenset())
472
+ class_types = CLASS_NODE_TYPES.get(language, frozenset())
473
+
474
+ def walk(node: Node, inside_function: bool) -> None:
475
+ if node.type in import_types:
476
+ for name_node in _import_bound_name_nodes(node, language):
477
+ bindings.import_bindings.append(
478
+ (name_node, _decode(source_bytes, name_node))
479
+ )
480
+ bindings.excluded_positions.add(name_node.start_byte)
481
+ return
482
+ if node.type in local_types and inside_function:
483
+ name_node = _local_bound_name_node(node, language)
484
+ if name_node is not None:
485
+ bindings.local_bindings.append(
486
+ (name_node, _decode(source_bytes, name_node))
487
+ )
488
+ bindings.excluded_positions.add(name_node.start_byte)
489
+ if node.type in function_types or node.type in class_types:
490
+ name_node = node.child_by_field_name("name")
491
+ if name_node is not None:
492
+ kind = "class" if node.type in class_types else "function"
493
+ bindings.definitions.append(
494
+ Definition(
495
+ name=_decode(source_bytes, name_node),
496
+ kind=kind,
497
+ file_path=file_path,
498
+ line=name_node.start_point[0] + 1,
499
+ column=name_node.start_point[1],
500
+ )
501
+ )
502
+ bindings.excluded_positions.add(name_node.start_byte)
503
+ inside_function = inside_function or node.type in function_types
504
+ for child in node.children:
505
+ walk(child, inside_function)
506
+
507
+ walk(tree.root_node, False)
508
+ return bindings
509
+
510
+
511
+ def _collect_usage_names(
512
+ tree: Tree, source_bytes: bytes, language: str, excluded_positions: set[int]
513
+ ) -> list[str]:
514
+ """Return every usage-identifier name in *tree*, skipping binding sites.
515
+
516
+ Author: Ron Webb
517
+ Since: 1.0.0
518
+ """
519
+ usage_types = USAGE_NODE_TYPES.get(language, frozenset())
520
+ return [
521
+ _decode(source_bytes, node)
522
+ for node in _iter_nodes(tree.root_node)
523
+ if node.type in usage_types and node.start_byte not in excluded_positions
524
+ ]
525
+
526
+
527
+ def detect_unused_bindings(
528
+ tree: Tree, source_bytes: bytes, language: str, file_path: Path
529
+ ) -> tuple[list[Finding], list[Definition], list[str]]:
530
+ """Detect unused imports/locals and collect data for cross-file analysis.
531
+
532
+ Returns a 3-tuple of ``(findings, definitions, usage_names)``. Imports and
533
+ local variables are resolved immediately (per-file); ``definitions`` and
534
+ ``usage_names`` are returned so the caller can resolve
535
+ :data:`CATEGORY_UNUSED_SYMBOL` once every file in the scan has been
536
+ processed.
537
+
538
+ Author: Ron Webb
539
+ Since: 1.0.0
540
+ """
541
+ bindings = _collect_bindings(tree, source_bytes, language, file_path)
542
+ usage_names = _collect_usage_names(
543
+ tree, source_bytes, language, bindings.excluded_positions
544
+ )
545
+ usage_counts = Counter(usage_names)
546
+
547
+ findings: list[Finding] = []
548
+ findings.extend(
549
+ _unused_binding_findings(
550
+ bindings.import_bindings,
551
+ usage_counts,
552
+ CATEGORY_UNUSED_IMPORT,
553
+ file_path,
554
+ "Unused import",
555
+ )
556
+ )
557
+ findings.extend(
558
+ _unused_binding_findings(
559
+ bindings.local_bindings,
560
+ usage_counts,
561
+ CATEGORY_UNUSED_LOCAL,
562
+ file_path,
563
+ "Unused local variable",
564
+ )
565
+ )
566
+ return findings, bindings.definitions, usage_names
567
+
568
+
569
+ def _unused_binding_findings(
570
+ bindings: Iterable[tuple[Node, str]],
571
+ usage_counts: Counter[str],
572
+ category: str,
573
+ file_path: Path,
574
+ message_prefix: str,
575
+ ) -> list[Finding]:
576
+ """Build :class:`Finding` objects for bindings never used elsewhere.
577
+
578
+ Author: Ron Webb
579
+ Since: 1.0.0
580
+ """
581
+ findings: list[Finding] = []
582
+ for name_node, name in bindings:
583
+ if usage_counts[name] == 0:
584
+ row, col = name_node.start_point
585
+ findings.append(
586
+ Finding(
587
+ category=category,
588
+ file_path=file_path,
589
+ line=row + 1,
590
+ column=col,
591
+ name=name,
592
+ message=f"{message_prefix} '{name}'.",
593
+ )
594
+ )
595
+ return findings
596
+
597
+
598
+ # --------------------------------------------------------------------------
599
+ # Obsolete markers
600
+ # --------------------------------------------------------------------------
601
+
602
+
603
+ def detect_obsolete_markers(
604
+ tree: Tree, source_bytes: bytes, language: str, file_path: Path
605
+ ) -> list[Finding]:
606
+ """Detect deprecation decorators/annotations and TODO/FIXME-style comments.
607
+
608
+ Author: Ron Webb
609
+ Since: 1.0.0
610
+ """
611
+ findings: list[Finding] = []
612
+ decorator_types = DECORATOR_NODE_TYPES.get(language, frozenset())
613
+ comment_types = COMMENT_NODE_TYPES.get(language, frozenset())
614
+
615
+ for node in _iter_nodes(tree.root_node):
616
+ if decorator_types and node.type in decorator_types:
617
+ text = _decode(source_bytes, node)
618
+ if re.search(r"deprecated", text, re.IGNORECASE):
619
+ row, col = node.start_point
620
+ findings.append(
621
+ Finding(
622
+ category=CATEGORY_OBSOLETE,
623
+ file_path=file_path,
624
+ line=row + 1,
625
+ column=col,
626
+ name=node.type,
627
+ message="Deprecated marker found.",
628
+ )
629
+ )
630
+ elif node.type in comment_types:
631
+ text = _decode(source_bytes, node)
632
+ match = _OBSOLETE_COMMENT_RE.search(text)
633
+ if match:
634
+ row, col = node.start_point
635
+ findings.append(
636
+ Finding(
637
+ category=CATEGORY_OBSOLETE,
638
+ file_path=file_path,
639
+ line=row + 1,
640
+ column=col,
641
+ name=match.group(1).upper(),
642
+ message=f"Obsolete marker '{match.group(1)}' found in comment.",
643
+ )
644
+ )
645
+ return findings
646
+
647
+
648
+ # --------------------------------------------------------------------------
649
+ # Commented-out code
650
+ # --------------------------------------------------------------------------
651
+
652
+
653
+ def _strip_comment_markers(text: str) -> str:
654
+ """Strip ``#``/``//``/``/* */`` comment delimiters from *text*.
655
+
656
+ Author: Ron Webb
657
+ Since: 1.0.0
658
+ """
659
+ stripped = text.strip()
660
+ for prefix in ("///", "//", "#"):
661
+ if stripped.startswith(prefix):
662
+ return stripped[len(prefix) :].strip()
663
+ if stripped.startswith("/*") and stripped.endswith("*/"):
664
+ return stripped[2:-2].strip()
665
+ return stripped.lstrip("*").strip()
666
+
667
+
668
+ def _looks_like_code(text: str) -> bool:
669
+ """Heuristically determine whether *text* resembles source code.
670
+
671
+ Author: Ron Webb
672
+ Since: 1.0.0
673
+ """
674
+ stripped = text.rstrip()
675
+ if not stripped:
676
+ return False
677
+ if _CODE_LIKE_RE.match(stripped):
678
+ return True
679
+ if _ASSIGNMENT_RE.search(stripped) and stripped.endswith((";", ":")):
680
+ return True
681
+ return stripped.endswith(("{", "}")) and len(stripped) > 1
682
+
683
+
684
+ def detect_commented_out_code(
685
+ tree: Tree, source_bytes: bytes, language: str, file_path: Path
686
+ ) -> list[Finding]:
687
+ """Detect comments whose content heuristically resembles source code.
688
+
689
+ Consecutive single-line comments are grouped into one block before the
690
+ heuristic is applied, so a multi-line commented-out snippet produces a
691
+ single finding.
692
+
693
+ Author: Ron Webb
694
+ Since: 1.0.0
695
+ """
696
+ comment_types = COMMENT_NODE_TYPES.get(language, frozenset())
697
+ if not comment_types:
698
+ return []
699
+ comments = [n for n in _iter_nodes(tree.root_node) if n.type in comment_types]
700
+ comments.sort(key=lambda n: n.start_point)
701
+
702
+ findings: list[Finding] = []
703
+ run: list[Node] = []
704
+
705
+ def flush() -> None:
706
+ if not run:
707
+ return
708
+ lines = [_strip_comment_markers(_decode(source_bytes, n)) for n in run]
709
+ if any(_looks_like_code(line) for line in lines):
710
+ row, col = run[0].start_point
711
+ snippet = next(line for line in lines if _looks_like_code(line))
712
+ findings.append(
713
+ Finding(
714
+ category=CATEGORY_COMMENTED_OUT,
715
+ file_path=file_path,
716
+ line=row + 1,
717
+ column=col,
718
+ name="",
719
+ message=f"Possible commented-out code: {snippet[:80]!r}.",
720
+ )
721
+ )
722
+ run.clear()
723
+
724
+ previous: Node | None = None
725
+ single_line_types = frozenset({"line_comment", "comment"})
726
+ for comment in comments:
727
+ is_adjacent = (
728
+ previous is not None
729
+ and previous.type in single_line_types
730
+ and comment.type in single_line_types
731
+ and comment.start_point[0] == previous.end_point[0] + 1
732
+ )
733
+ if run and not is_adjacent:
734
+ flush()
735
+ run.append(comment)
736
+ previous = comment
737
+ flush()
738
+
739
+ return findings
740
+
741
+
742
+ # --------------------------------------------------------------------------
743
+ # Orchestration
744
+ # --------------------------------------------------------------------------
745
+
746
+
747
+ def scan_source(source_bytes: bytes, file_path: Path, language: str) -> ScanResult:
748
+ """Run every detector against *source_bytes* and return a combined result.
749
+
750
+ Author: Ron Webb
751
+ Since: 1.0.0
752
+ """
753
+ tree = parse(source_bytes, language)
754
+ if tree is None:
755
+ return ScanResult()
756
+
757
+ findings: list[Finding] = []
758
+ findings.extend(detect_unreachable(tree, source_bytes, language, file_path))
759
+ unused_findings, definitions, usages = detect_unused_bindings(
760
+ tree, source_bytes, language, file_path
761
+ )
762
+ findings.extend(unused_findings)
763
+ findings.extend(detect_obsolete_markers(tree, source_bytes, language, file_path))
764
+ findings.extend(detect_commented_out_code(tree, source_bytes, language, file_path))
765
+
766
+ return ScanResult(findings=findings, definitions=definitions, usages=usages)
767
+
768
+
769
+ def scan_file(file_path: Path) -> ScanResult:
770
+ """Read *file_path* from disk and scan it for dead-code findings.
771
+
772
+ The tree-sitter language is determined from the file extension via
773
+ :data:`EXTENSION_TO_LANGUAGE`. Files with unsupported extensions are
774
+ skipped with a warning and an empty result is returned.
775
+
776
+ Author: Ron Webb
777
+ Since: 1.0.0
778
+ """
779
+ language = EXTENSION_TO_LANGUAGE.get(file_path.suffix.lower())
780
+ if language is None:
781
+ _logger.warning(
782
+ "Unsupported extension '%s' — skipping %s", file_path.suffix, file_path
783
+ )
784
+ return ScanResult()
785
+ source_bytes = file_path.read_bytes()
786
+ return scan_source(source_bytes, file_path, language)
787
+
788
+
789
+ def load_name_ignore_patterns(path: Path) -> list[re.Pattern[str]]:
790
+ """Load regex symbol-name ignore patterns from *path*.
791
+
792
+ Lines starting with ``#`` and blank lines are skipped. Author: Ron Webb
793
+ Since: 1.0.0
794
+ """
795
+ patterns: list[re.Pattern[str]] = []
796
+ for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
797
+ stripped = line.strip()
798
+ if stripped and not stripped.startswith("#"):
799
+ patterns.append(re.compile(stripped))
800
+ return patterns