ballpython 2.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.
- ballpython/__init__.py +9 -0
- ballpython/__main__.py +8 -0
- ballpython/cli.py +7 -0
- ballpython-2.0.0.dist-info/METADATA +322 -0
- ballpython-2.0.0.dist-info/RECORD +24 -0
- ballpython-2.0.0.dist-info/WHEEL +5 -0
- ballpython-2.0.0.dist-info/entry_points.txt +3 -0
- ballpython-2.0.0.dist-info/top_level.txt +2 -0
- pycleaner/__init__.py +53 -0
- pycleaner/__main__.py +8 -0
- pycleaner/cli.py +1548 -0
- pycleaner/complexity_analyzer.py +473 -0
- pycleaner/config.py +254 -0
- pycleaner/dead_code_detector.py +515 -0
- pycleaner/dependency_auditor.py +331 -0
- pycleaner/import_resolver.py +832 -0
- pycleaner/linter_formatter.py +590 -0
- pycleaner/pipeline.py +349 -0
- pycleaner/security_scanner.py +563 -0
- pycleaner/syntax_healer.py +577 -0
- pycleaner/taint_engine.py +720 -0
- pycleaner/test_generator.py +444 -0
- pycleaner/type_checker.py +989 -0
- pycleaner/typeshed_resolver.py +395 -0
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Dead code detector for Python codebases.
|
|
3
|
+
|
|
4
|
+
AST-based analysis that identifies unused functions, classes, variables,
|
|
5
|
+
unreachable code after return/raise/break/continue, and empty pass branches.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import ast
|
|
11
|
+
import os
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import ClassVar
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(slots=True)
|
|
18
|
+
class DeadCodeItem:
|
|
19
|
+
"""A single piece of detected dead code."""
|
|
20
|
+
|
|
21
|
+
filepath: str
|
|
22
|
+
lineno: int
|
|
23
|
+
end_lineno: int | None
|
|
24
|
+
name: str
|
|
25
|
+
kind: str # 'function', 'class', 'variable', 'unreachable', 'empty-branch', 'unused-import'
|
|
26
|
+
reason: str
|
|
27
|
+
confidence: str = "high" # 'high', 'medium', 'low'
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(slots=True)
|
|
31
|
+
class DeadCodeReport:
|
|
32
|
+
"""Full dead code analysis report for a project."""
|
|
33
|
+
|
|
34
|
+
items: list[DeadCodeItem] = field(default_factory=list)
|
|
35
|
+
files_scanned: int = 0
|
|
36
|
+
total_definitions: int = 0
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def count(self) -> int:
|
|
40
|
+
return len(self.items)
|
|
41
|
+
|
|
42
|
+
def by_kind(self, kind: str) -> list[DeadCodeItem]:
|
|
43
|
+
return [item for item in self.items if item.kind == kind]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class _DefinitionCollector(ast.NodeVisitor):
|
|
47
|
+
"""Collects all function and class definitions with their line numbers."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, filepath: str) -> None:
|
|
50
|
+
self.filepath = filepath
|
|
51
|
+
self.definitions: list[tuple[str, str, int, int | None, str]] = []
|
|
52
|
+
# (name, kind, lineno, end_lineno, scope_context)
|
|
53
|
+
self._scope_stack: list[str] = []
|
|
54
|
+
|
|
55
|
+
def visit_FunctionDef(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
|
|
56
|
+
context = ".".join(self._scope_stack) if self._scope_stack else "<module>"
|
|
57
|
+
self.definitions.append(
|
|
58
|
+
(node.name, "function", node.lineno, node.end_lineno, context)
|
|
59
|
+
)
|
|
60
|
+
self._scope_stack.append(node.name)
|
|
61
|
+
self.generic_visit(node)
|
|
62
|
+
self._scope_stack.pop()
|
|
63
|
+
|
|
64
|
+
visit_AsyncFunctionDef = visit_FunctionDef
|
|
65
|
+
|
|
66
|
+
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
|
67
|
+
context = ".".join(self._scope_stack) if self._scope_stack else "<module>"
|
|
68
|
+
self.definitions.append(
|
|
69
|
+
(node.name, "class", node.lineno, node.end_lineno, context)
|
|
70
|
+
)
|
|
71
|
+
self._scope_stack.append(node.name)
|
|
72
|
+
self.generic_visit(node)
|
|
73
|
+
self._scope_stack.pop()
|
|
74
|
+
|
|
75
|
+
def visit_Assign(self, node: ast.Assign) -> None:
|
|
76
|
+
# Module-level constants or class attributes (not local function variables)
|
|
77
|
+
if not self._scope_stack or len(self._scope_stack) == 1:
|
|
78
|
+
for target in node.targets:
|
|
79
|
+
if isinstance(target, ast.Name):
|
|
80
|
+
name = target.id
|
|
81
|
+
if not name.startswith("__"):
|
|
82
|
+
context = (
|
|
83
|
+
".".join(self._scope_stack)
|
|
84
|
+
if self._scope_stack
|
|
85
|
+
else "<module>"
|
|
86
|
+
)
|
|
87
|
+
self.definitions.append(
|
|
88
|
+
(name, "variable", node.lineno, node.end_lineno, context)
|
|
89
|
+
)
|
|
90
|
+
self.generic_visit(node)
|
|
91
|
+
|
|
92
|
+
def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
|
|
93
|
+
if (not self._scope_stack or len(self._scope_stack) == 1) and isinstance(
|
|
94
|
+
node.target, ast.Name
|
|
95
|
+
):
|
|
96
|
+
name = node.target.id
|
|
97
|
+
if not name.startswith("__"):
|
|
98
|
+
context = (
|
|
99
|
+
".".join(self._scope_stack) if self._scope_stack else "<module>"
|
|
100
|
+
)
|
|
101
|
+
self.definitions.append(
|
|
102
|
+
(name, "variable", node.lineno, node.end_lineno, context)
|
|
103
|
+
)
|
|
104
|
+
self.generic_visit(node)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class _ReferenceCollector(ast.NodeVisitor):
|
|
108
|
+
"""Collects all name references (loads) across source code."""
|
|
109
|
+
|
|
110
|
+
def __init__(self) -> None:
|
|
111
|
+
self.referenced_names: set[str] = set()
|
|
112
|
+
self.all_exports: set[str] = set()
|
|
113
|
+
self.decorated_names: set[str] = set()
|
|
114
|
+
|
|
115
|
+
def visit_Name(self, node: ast.Name) -> None:
|
|
116
|
+
if isinstance(node.ctx, ast.Load):
|
|
117
|
+
self.referenced_names.add(node.id)
|
|
118
|
+
self.generic_visit(node)
|
|
119
|
+
|
|
120
|
+
def visit_Attribute(self, node: ast.Attribute) -> None:
|
|
121
|
+
self.referenced_names.add(node.attr)
|
|
122
|
+
self.generic_visit(node)
|
|
123
|
+
|
|
124
|
+
def visit_FunctionDef(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
|
|
125
|
+
if node.decorator_list:
|
|
126
|
+
self.decorated_names.add(node.name)
|
|
127
|
+
self.generic_visit(node)
|
|
128
|
+
|
|
129
|
+
visit_AsyncFunctionDef = visit_FunctionDef
|
|
130
|
+
|
|
131
|
+
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
|
132
|
+
if node.decorator_list:
|
|
133
|
+
self.decorated_names.add(node.name)
|
|
134
|
+
self.generic_visit(node)
|
|
135
|
+
|
|
136
|
+
def visit_Assign(self, node: ast.Assign) -> None:
|
|
137
|
+
# Detect __all__ = ['name1', 'name2']
|
|
138
|
+
for target in node.targets:
|
|
139
|
+
if (
|
|
140
|
+
isinstance(target, ast.Name)
|
|
141
|
+
and target.id == "__all__"
|
|
142
|
+
and isinstance(node.value, (ast.List, ast.Tuple, ast.Set))
|
|
143
|
+
):
|
|
144
|
+
for elt in node.value.elts:
|
|
145
|
+
if isinstance(elt, ast.Constant) and isinstance(elt.value, str):
|
|
146
|
+
self.all_exports.add(elt.value)
|
|
147
|
+
self.generic_visit(node)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class _UnreachableCodeDetector(ast.NodeVisitor):
|
|
151
|
+
"""Detects code after unconditional return/raise/break/continue and empty branches."""
|
|
152
|
+
|
|
153
|
+
def __init__(self, filepath: str, source_lines: list[str] | None = None) -> None:
|
|
154
|
+
self.filepath = filepath
|
|
155
|
+
self.source_lines = source_lines
|
|
156
|
+
self.items: list[DeadCodeItem] = []
|
|
157
|
+
|
|
158
|
+
def visit_Module(self, node: ast.Module) -> None:
|
|
159
|
+
self._check_body(node.body)
|
|
160
|
+
self.generic_visit(node)
|
|
161
|
+
|
|
162
|
+
def visit_FunctionDef(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
|
|
163
|
+
self._check_body(node.body)
|
|
164
|
+
self.generic_visit(node)
|
|
165
|
+
|
|
166
|
+
visit_AsyncFunctionDef = visit_FunctionDef
|
|
167
|
+
|
|
168
|
+
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
|
169
|
+
self._check_body(node.body)
|
|
170
|
+
self.generic_visit(node)
|
|
171
|
+
|
|
172
|
+
def visit_If(self, node: ast.If) -> None:
|
|
173
|
+
self._check_body(node.body)
|
|
174
|
+
self._check_empty_branch(node.body, "if", node.lineno)
|
|
175
|
+
if node.orelse:
|
|
176
|
+
self._check_body(node.orelse)
|
|
177
|
+
first_orelse = node.orelse[0]
|
|
178
|
+
label = "elif" if isinstance(first_orelse, ast.If) else "else"
|
|
179
|
+
lineno = (
|
|
180
|
+
first_orelse.lineno
|
|
181
|
+
if isinstance(first_orelse, ast.If)
|
|
182
|
+
else node.orelse[0].lineno
|
|
183
|
+
)
|
|
184
|
+
self._check_empty_branch(node.orelse, label, lineno)
|
|
185
|
+
self.generic_visit(node)
|
|
186
|
+
|
|
187
|
+
def visit_Try(self, node: ast.Try) -> None:
|
|
188
|
+
self._check_body(node.body)
|
|
189
|
+
self._check_body(node.finalbody)
|
|
190
|
+
self._check_body(node.orelse)
|
|
191
|
+
self.generic_visit(node)
|
|
192
|
+
|
|
193
|
+
def visit_ExceptHandler(self, node: ast.ExceptHandler) -> None:
|
|
194
|
+
self._check_body(node.body)
|
|
195
|
+
self._check_empty_branch(node.body, "except", node.lineno)
|
|
196
|
+
self.generic_visit(node)
|
|
197
|
+
|
|
198
|
+
def visit_For(self, node: ast.For | ast.AsyncFor) -> None:
|
|
199
|
+
self._check_body(node.body)
|
|
200
|
+
self.generic_visit(node)
|
|
201
|
+
|
|
202
|
+
visit_AsyncFor = visit_For
|
|
203
|
+
|
|
204
|
+
def visit_While(self, node: ast.While) -> None:
|
|
205
|
+
self._check_body(node.body)
|
|
206
|
+
self.generic_visit(node)
|
|
207
|
+
|
|
208
|
+
def _check_body(self, body: list[ast.stmt]) -> None:
|
|
209
|
+
"""Check for unreachable code after return/raise/break/continue."""
|
|
210
|
+
for i, stmt in enumerate(body):
|
|
211
|
+
if isinstance(stmt, (ast.Return, ast.Raise, ast.Break, ast.Continue)):
|
|
212
|
+
remaining = body[i + 1 :]
|
|
213
|
+
for unreachable in remaining:
|
|
214
|
+
# Skip if the unreachable statement is a function/class def (declarations)
|
|
215
|
+
if isinstance(
|
|
216
|
+
unreachable,
|
|
217
|
+
(ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef),
|
|
218
|
+
):
|
|
219
|
+
continue
|
|
220
|
+
self.items.append(
|
|
221
|
+
DeadCodeItem(
|
|
222
|
+
filepath=self.filepath,
|
|
223
|
+
lineno=unreachable.lineno,
|
|
224
|
+
end_lineno=getattr(unreachable, "end_lineno", None),
|
|
225
|
+
name="<unreachable>",
|
|
226
|
+
kind="unreachable",
|
|
227
|
+
reason=f"Code after unconditional {type(stmt).__name__.lower()} on line {stmt.lineno}",
|
|
228
|
+
confidence="high",
|
|
229
|
+
)
|
|
230
|
+
)
|
|
231
|
+
break
|
|
232
|
+
|
|
233
|
+
def _check_empty_branch(
|
|
234
|
+
self, body: list[ast.stmt], branch_kind: str, lineno: int
|
|
235
|
+
) -> None:
|
|
236
|
+
"""Check for empty branches containing only pass with no comment."""
|
|
237
|
+
if len(body) == 1 and isinstance(body[0], ast.Pass):
|
|
238
|
+
pass_node = body[0]
|
|
239
|
+
if self.source_lines:
|
|
240
|
+
idx = pass_node.lineno - 1
|
|
241
|
+
if 0 <= idx < len(self.source_lines) and "#" in self.source_lines[idx]:
|
|
242
|
+
return
|
|
243
|
+
# Check if preceding line is an explanatory comment
|
|
244
|
+
if (
|
|
245
|
+
idx > 0
|
|
246
|
+
and 0 <= idx - 1 < len(self.source_lines)
|
|
247
|
+
and self.source_lines[idx - 1].strip().startswith("#")
|
|
248
|
+
):
|
|
249
|
+
return
|
|
250
|
+
self.items.append(
|
|
251
|
+
DeadCodeItem(
|
|
252
|
+
filepath=self.filepath,
|
|
253
|
+
lineno=lineno,
|
|
254
|
+
end_lineno=pass_node.lineno,
|
|
255
|
+
name=f"empty {branch_kind}",
|
|
256
|
+
kind="empty-branch",
|
|
257
|
+
reason=f"'{branch_kind}' block contains only 'pass' with no implementation",
|
|
258
|
+
confidence="low",
|
|
259
|
+
)
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
@dataclass(slots=True)
|
|
264
|
+
class _ProjectScanState:
|
|
265
|
+
definitions: list[tuple[str, str, str, int, int | None, str]] = field(
|
|
266
|
+
default_factory=list
|
|
267
|
+
)
|
|
268
|
+
references: set[str] = field(default_factory=set)
|
|
269
|
+
exports: set[str] = field(default_factory=set)
|
|
270
|
+
decorated: set[str] = field(default_factory=set)
|
|
271
|
+
unreachable: list[DeadCodeItem] = field(default_factory=list)
|
|
272
|
+
|
|
273
|
+
def process_file(self, py_file: Path) -> None:
|
|
274
|
+
try:
|
|
275
|
+
content = py_file.read_text(encoding="utf-8", errors="replace")
|
|
276
|
+
tree = ast.parse(content, filename=str(py_file))
|
|
277
|
+
except SyntaxError:
|
|
278
|
+
return
|
|
279
|
+
|
|
280
|
+
filepath_str = str(py_file)
|
|
281
|
+
def_collector = _DefinitionCollector(filepath_str)
|
|
282
|
+
def_collector.visit(tree)
|
|
283
|
+
for name, kind, lineno, end_lineno, ctx in def_collector.definitions:
|
|
284
|
+
self.definitions.append((filepath_str, name, kind, lineno, end_lineno, ctx))
|
|
285
|
+
|
|
286
|
+
ref_collector = _ReferenceCollector()
|
|
287
|
+
ref_collector.visit(tree)
|
|
288
|
+
self.references.update(ref_collector.referenced_names)
|
|
289
|
+
self.exports.update(ref_collector.all_exports)
|
|
290
|
+
self.decorated.update(ref_collector.decorated_names)
|
|
291
|
+
|
|
292
|
+
unreachable = _UnreachableCodeDetector(filepath_str, content.splitlines())
|
|
293
|
+
unreachable.visit(tree)
|
|
294
|
+
self.unreachable.extend(unreachable.items)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
class DeadCodeDetector:
|
|
298
|
+
"""Detects dead code across a Python project."""
|
|
299
|
+
|
|
300
|
+
IGNORE_DIRS = frozenset(
|
|
301
|
+
{
|
|
302
|
+
".git",
|
|
303
|
+
".venv",
|
|
304
|
+
"venv",
|
|
305
|
+
"env",
|
|
306
|
+
"__pycache__",
|
|
307
|
+
"build",
|
|
308
|
+
"dist",
|
|
309
|
+
".tox",
|
|
310
|
+
".mypy_cache",
|
|
311
|
+
".pytest_cache",
|
|
312
|
+
".ruff_cache",
|
|
313
|
+
"site-packages",
|
|
314
|
+
"node_modules",
|
|
315
|
+
".eggs",
|
|
316
|
+
}
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
# Names that should never be flagged as dead code
|
|
320
|
+
PROTECTED_NAMES = frozenset(
|
|
321
|
+
{
|
|
322
|
+
"__init__",
|
|
323
|
+
"__new__",
|
|
324
|
+
"__del__",
|
|
325
|
+
"__repr__",
|
|
326
|
+
"__str__",
|
|
327
|
+
"__bytes__",
|
|
328
|
+
"__format__",
|
|
329
|
+
"__hash__",
|
|
330
|
+
"__bool__",
|
|
331
|
+
"__len__",
|
|
332
|
+
"__getitem__",
|
|
333
|
+
"__setitem__",
|
|
334
|
+
"__delitem__",
|
|
335
|
+
"__iter__",
|
|
336
|
+
"__next__",
|
|
337
|
+
"__contains__",
|
|
338
|
+
"__enter__",
|
|
339
|
+
"__exit__",
|
|
340
|
+
"__aenter__",
|
|
341
|
+
"__aexit__",
|
|
342
|
+
"__await__",
|
|
343
|
+
"__aiter__",
|
|
344
|
+
"__anext__",
|
|
345
|
+
"__call__",
|
|
346
|
+
"__eq__",
|
|
347
|
+
"__ne__",
|
|
348
|
+
"__lt__",
|
|
349
|
+
"__le__",
|
|
350
|
+
"__gt__",
|
|
351
|
+
"__ge__",
|
|
352
|
+
"__add__",
|
|
353
|
+
"__radd__",
|
|
354
|
+
"__sub__",
|
|
355
|
+
"__mul__",
|
|
356
|
+
"__truediv__",
|
|
357
|
+
"__floordiv__",
|
|
358
|
+
"__mod__",
|
|
359
|
+
"__pow__",
|
|
360
|
+
"__and__",
|
|
361
|
+
"__or__",
|
|
362
|
+
"__xor__",
|
|
363
|
+
"__neg__",
|
|
364
|
+
"__pos__",
|
|
365
|
+
"__abs__",
|
|
366
|
+
"__invert__",
|
|
367
|
+
"__getattr__",
|
|
368
|
+
"__setattr__",
|
|
369
|
+
"__delattr__",
|
|
370
|
+
"__get__",
|
|
371
|
+
"__set__",
|
|
372
|
+
"__delete__",
|
|
373
|
+
"__init_subclass__",
|
|
374
|
+
"__class_getitem__",
|
|
375
|
+
"__post_init__",
|
|
376
|
+
"__set_name__",
|
|
377
|
+
"setUp",
|
|
378
|
+
"tearDown",
|
|
379
|
+
"setUpClass",
|
|
380
|
+
"tearDownClass",
|
|
381
|
+
"main",
|
|
382
|
+
"setup",
|
|
383
|
+
"teardown",
|
|
384
|
+
}
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
FRAMEWORK_DECORATORS: ClassVar[frozenset[str]] = frozenset(
|
|
388
|
+
{
|
|
389
|
+
"app.route",
|
|
390
|
+
"router.get",
|
|
391
|
+
"router.post",
|
|
392
|
+
"router.put",
|
|
393
|
+
"router.delete",
|
|
394
|
+
"router.patch",
|
|
395
|
+
"pytest.fixture",
|
|
396
|
+
"abstractmethod",
|
|
397
|
+
"staticmethod",
|
|
398
|
+
"classmethod",
|
|
399
|
+
"property",
|
|
400
|
+
"override",
|
|
401
|
+
"register",
|
|
402
|
+
"receiver",
|
|
403
|
+
"celery.task",
|
|
404
|
+
"click.command",
|
|
405
|
+
"click.group",
|
|
406
|
+
"app.get",
|
|
407
|
+
"app.post",
|
|
408
|
+
"app.put",
|
|
409
|
+
"app.delete",
|
|
410
|
+
"app.patch",
|
|
411
|
+
"app.task",
|
|
412
|
+
"app.on_event",
|
|
413
|
+
"on_event",
|
|
414
|
+
}
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
def __init__(
|
|
418
|
+
self,
|
|
419
|
+
ignore_decorators: set[str] | None = None,
|
|
420
|
+
ignore_names: set[str] | None = None,
|
|
421
|
+
) -> None:
|
|
422
|
+
self.ignore_decorators = (ignore_decorators or set()) | set(
|
|
423
|
+
self.FRAMEWORK_DECORATORS
|
|
424
|
+
)
|
|
425
|
+
self.ignore_names = ignore_names or set()
|
|
426
|
+
|
|
427
|
+
def scan_project(self, root_dir: Path | str) -> DeadCodeReport:
|
|
428
|
+
"""Scan an entire project directory for dead code."""
|
|
429
|
+
root = Path(root_dir).resolve()
|
|
430
|
+
py_files = self._discover_files(root)
|
|
431
|
+
|
|
432
|
+
state = _ProjectScanState()
|
|
433
|
+
for py_file in py_files:
|
|
434
|
+
state.process_file(py_file)
|
|
435
|
+
|
|
436
|
+
items: list[DeadCodeItem] = list(state.unreachable)
|
|
437
|
+
for filepath, name, kind, lineno, end_lineno, _ in state.definitions:
|
|
438
|
+
if self._should_skip(
|
|
439
|
+
name, state.references, state.exports, state.decorated
|
|
440
|
+
):
|
|
441
|
+
continue
|
|
442
|
+
items.append(
|
|
443
|
+
DeadCodeItem(
|
|
444
|
+
filepath=filepath,
|
|
445
|
+
lineno=lineno,
|
|
446
|
+
end_lineno=end_lineno,
|
|
447
|
+
name=name,
|
|
448
|
+
kind=kind,
|
|
449
|
+
reason=f"{kind.capitalize()} '{name}' is defined but never referenced in the project",
|
|
450
|
+
confidence="medium",
|
|
451
|
+
)
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
items.sort(key=lambda x: (x.filepath, x.lineno))
|
|
455
|
+
return DeadCodeReport(
|
|
456
|
+
items=items,
|
|
457
|
+
files_scanned=len(py_files),
|
|
458
|
+
total_definitions=len(state.definitions),
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
def scan_source(self, source: str, filename: str = "<unknown>") -> DeadCodeReport:
|
|
462
|
+
"""Scan a single source string for dead code patterns (unreachable/empty only)."""
|
|
463
|
+
try:
|
|
464
|
+
tree = ast.parse(source, filename=filename)
|
|
465
|
+
except SyntaxError:
|
|
466
|
+
return DeadCodeReport()
|
|
467
|
+
|
|
468
|
+
unreachable = _UnreachableCodeDetector(filename, source.splitlines())
|
|
469
|
+
unreachable.visit(tree)
|
|
470
|
+
|
|
471
|
+
return DeadCodeReport(
|
|
472
|
+
items=unreachable.items,
|
|
473
|
+
files_scanned=1,
|
|
474
|
+
total_definitions=0,
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
def _matches_ignore_pattern(self, name: str) -> bool:
|
|
478
|
+
for pattern in self.ignore_names:
|
|
479
|
+
if pattern.endswith("*") and name.startswith(pattern[:-1]):
|
|
480
|
+
return True
|
|
481
|
+
if name == pattern:
|
|
482
|
+
return True
|
|
483
|
+
return False
|
|
484
|
+
|
|
485
|
+
def _is_name_exempt(self, name: str) -> bool:
|
|
486
|
+
if name in self.PROTECTED_NAMES:
|
|
487
|
+
return True
|
|
488
|
+
exempt_prefixes = ("_", "test_", "Test", "visit_")
|
|
489
|
+
return name.startswith(exempt_prefixes) or name == "generic_visit"
|
|
490
|
+
|
|
491
|
+
def _should_skip(
|
|
492
|
+
self,
|
|
493
|
+
name: str,
|
|
494
|
+
references: set[str],
|
|
495
|
+
exports: set[str],
|
|
496
|
+
decorated: set[str],
|
|
497
|
+
) -> bool:
|
|
498
|
+
"""Determine if a definition should be skipped (not flagged as dead)."""
|
|
499
|
+
if name in references or name in exports or name in decorated:
|
|
500
|
+
return True
|
|
501
|
+
if self._is_name_exempt(name):
|
|
502
|
+
return True
|
|
503
|
+
return self._matches_ignore_pattern(name)
|
|
504
|
+
|
|
505
|
+
def _discover_files(self, root: Path) -> list[Path]:
|
|
506
|
+
"""Walk the project tree and collect .py files, respecting ignore dirs."""
|
|
507
|
+
files: list[Path] = []
|
|
508
|
+
for current_root, dirs, filenames in os.walk(root):
|
|
509
|
+
dirs[:] = [
|
|
510
|
+
d for d in dirs if d not in self.IGNORE_DIRS and not d.startswith(".")
|
|
511
|
+
]
|
|
512
|
+
for fname in filenames:
|
|
513
|
+
if fname.endswith(".py"):
|
|
514
|
+
files.append(Path(current_root) / fname)
|
|
515
|
+
return sorted(files)
|