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/.codepruneignore +9 -0
- code_prune/__init__.py +25 -0
- code_prune/cli.py +475 -0
- code_prune/config.ini +2 -0
- code_prune/config.py +44 -0
- code_prune/logging.ini +28 -0
- code_prune/parser.py +67 -0
- code_prune/reporter.py +360 -0
- code_prune/scanner.py +800 -0
- code_prune/store.py +266 -0
- code_prune/symbol_ignore +18 -0
- code_prune-1.0.0.dist-info/METADATA +164 -0
- code_prune-1.0.0.dist-info/RECORD +16 -0
- code_prune-1.0.0.dist-info/WHEEL +4 -0
- code_prune-1.0.0.dist-info/entry_points.txt +3 -0
- code_prune-1.0.0.dist-info/licenses/LICENSE +21 -0
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
|