ballpython 2.0.7__py3-none-any.whl → 2.1.1__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-2.0.7.dist-info → ballpython-2.1.1.dist-info}/METADATA +1 -3
- ballpython-2.1.1.dist-info/RECORD +34 -0
- pycleaner/__init__.py +1 -1
- pycleaner/cache.py +3 -1
- pycleaner/cli.py +228 -49
- pycleaner/complexity_analyzer.py +11 -8
- pycleaner/dead_code_detector.py +653 -33
- pycleaner/dependency_auditor.py +153 -17
- pycleaner/discovery.py +17 -0
- pycleaner/linter_formatter.py +6 -5
- pycleaner/modernizer.py +2 -1
- pycleaner/pipeline.py +99 -11
- pycleaner/security_scanner.py +151 -15
- pycleaner/syntax_healer.py +210 -44
- pycleaner/taint_engine.py +19 -15
- pycleaner/type_checker.py +128 -25
- pycleaner/verifier.py +15 -6
- ballpython-2.0.7.dist-info/RECORD +0 -34
- {ballpython-2.0.7.dist-info → ballpython-2.1.1.dist-info}/WHEEL +0 -0
- {ballpython-2.0.7.dist-info → ballpython-2.1.1.dist-info}/entry_points.txt +0 -0
- {ballpython-2.0.7.dist-info → ballpython-2.1.1.dist-info}/top_level.txt +0 -0
pycleaner/dead_code_detector.py
CHANGED
|
@@ -8,6 +8,7 @@ unreachable code after return/raise/break/continue, and empty pass branches.
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
10
|
import ast
|
|
11
|
+
import logging
|
|
11
12
|
from collections.abc import Sequence
|
|
12
13
|
from dataclasses import dataclass, field
|
|
13
14
|
from pathlib import Path
|
|
@@ -15,6 +16,48 @@ from typing import ClassVar
|
|
|
15
16
|
|
|
16
17
|
from pycleaner.discovery import collect_project_python_files
|
|
17
18
|
from pycleaner.frameworks import FrameworkRegistry, get_default_registry
|
|
19
|
+
from pycleaner.security_scanner import is_secret_ast_node
|
|
20
|
+
|
|
21
|
+
PROTECTED_NAMES: frozenset[str] = frozenset(
|
|
22
|
+
{
|
|
23
|
+
"AWS_KEY",
|
|
24
|
+
"AWS_SECRET",
|
|
25
|
+
"GITHUB_TOKEN",
|
|
26
|
+
"STRIPE_KEY",
|
|
27
|
+
"SLACK_TOKEN",
|
|
28
|
+
"SENDGRID",
|
|
29
|
+
"DSN",
|
|
30
|
+
"DATABASE_URL",
|
|
31
|
+
"PRIVATE_KEY",
|
|
32
|
+
"PEM",
|
|
33
|
+
"SECRET_KEY",
|
|
34
|
+
"API_KEY",
|
|
35
|
+
}
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _is_protected_variable(name: str, value_node: ast.AST | None = None) -> bool:
|
|
40
|
+
"""Check if a variable name or assigned value represents a protected secret or config."""
|
|
41
|
+
name_upper = name.upper()
|
|
42
|
+
if name_upper in PROTECTED_NAMES:
|
|
43
|
+
return True
|
|
44
|
+
if any(
|
|
45
|
+
kw in name_upper
|
|
46
|
+
for kw in (
|
|
47
|
+
"SECRET",
|
|
48
|
+
"TOKEN",
|
|
49
|
+
"API_KEY",
|
|
50
|
+
"APIKEY",
|
|
51
|
+
"PASSWORD",
|
|
52
|
+
"PASSWD",
|
|
53
|
+
"PRIVATE_KEY",
|
|
54
|
+
"CREDENTIAL",
|
|
55
|
+
)
|
|
56
|
+
):
|
|
57
|
+
return True
|
|
58
|
+
if value_node is not None and is_secret_ast_node(value_node):
|
|
59
|
+
return True
|
|
60
|
+
return False
|
|
18
61
|
|
|
19
62
|
|
|
20
63
|
@dataclass(slots=True)
|
|
@@ -126,6 +169,8 @@ class _DefinitionCollector(ast.NodeVisitor):
|
|
|
126
169
|
name, "variable", node, context, self.tree, self.filepath
|
|
127
170
|
):
|
|
128
171
|
continue
|
|
172
|
+
if _is_protected_variable(name, node.value):
|
|
173
|
+
continue
|
|
129
174
|
self.definitions.append(
|
|
130
175
|
(name, "variable", node.lineno, node.end_lineno, context)
|
|
131
176
|
)
|
|
@@ -152,6 +197,9 @@ class _DefinitionCollector(ast.NodeVisitor):
|
|
|
152
197
|
):
|
|
153
198
|
self.generic_visit(node)
|
|
154
199
|
return
|
|
200
|
+
if _is_protected_variable(name, node.value):
|
|
201
|
+
self.generic_visit(node)
|
|
202
|
+
return
|
|
155
203
|
self.definitions.append(
|
|
156
204
|
(name, "variable", node.lineno, node.end_lineno, context)
|
|
157
205
|
)
|
|
@@ -201,6 +249,202 @@ class _ReferenceCollector(ast.NodeVisitor):
|
|
|
201
249
|
self.generic_visit(node)
|
|
202
250
|
|
|
203
251
|
|
|
252
|
+
def _is_pure_expression(node: ast.AST | None) -> bool:
|
|
253
|
+
"""Determine if an AST expression is pure (guaranteed free of side effects)."""
|
|
254
|
+
if node is None:
|
|
255
|
+
return True
|
|
256
|
+
if isinstance(node, ast.Constant):
|
|
257
|
+
return True
|
|
258
|
+
if isinstance(node, ast.Name):
|
|
259
|
+
return True
|
|
260
|
+
if isinstance(node, (ast.Tuple, ast.List, ast.Set)):
|
|
261
|
+
return all(_is_pure_expression(elt) for elt in node.elts)
|
|
262
|
+
if isinstance(node, ast.Dict):
|
|
263
|
+
return all(
|
|
264
|
+
(k is None or _is_pure_expression(k)) and _is_pure_expression(v)
|
|
265
|
+
for k, v in zip(node.keys, node.values)
|
|
266
|
+
)
|
|
267
|
+
if isinstance(node, ast.UnaryOp):
|
|
268
|
+
return _is_pure_expression(node.operand)
|
|
269
|
+
if isinstance(node, ast.BinOp):
|
|
270
|
+
return _is_pure_expression(node.left) and _is_pure_expression(node.right)
|
|
271
|
+
if isinstance(node, ast.BoolOp):
|
|
272
|
+
return all(_is_pure_expression(v) for v in node.values)
|
|
273
|
+
if isinstance(node, ast.Compare):
|
|
274
|
+
return _is_pure_expression(node.left) and all(
|
|
275
|
+
_is_pure_expression(c) for c in node.comparators
|
|
276
|
+
)
|
|
277
|
+
if isinstance(node, ast.JoinedStr):
|
|
278
|
+
for val in node.values:
|
|
279
|
+
if isinstance(val, ast.FormattedValue) and not _is_pure_expression(
|
|
280
|
+
val.value
|
|
281
|
+
):
|
|
282
|
+
return False
|
|
283
|
+
return True
|
|
284
|
+
if isinstance(node, ast.Lambda):
|
|
285
|
+
return True
|
|
286
|
+
return False
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _walk_stmts_in_scope(body: list[ast.stmt]) -> list[tuple[ast.stmt, list[ast.stmt]]]:
|
|
290
|
+
"""Yield (stmt, parent_body) for statements within this scope without descending into nested functions/classes."""
|
|
291
|
+
result: list[tuple[ast.stmt, list[ast.stmt]]] = []
|
|
292
|
+
for s in body:
|
|
293
|
+
result.append((s, body))
|
|
294
|
+
if isinstance(s, (ast.If, ast.While, ast.For, ast.AsyncFor)):
|
|
295
|
+
result.extend(_walk_stmts_in_scope(s.body))
|
|
296
|
+
if s.orelse:
|
|
297
|
+
result.extend(_walk_stmts_in_scope(s.orelse))
|
|
298
|
+
elif isinstance(s, ast.Try):
|
|
299
|
+
result.extend(_walk_stmts_in_scope(s.body))
|
|
300
|
+
for h in s.handlers:
|
|
301
|
+
result.extend(_walk_stmts_in_scope(h.body))
|
|
302
|
+
if s.orelse:
|
|
303
|
+
result.extend(_walk_stmts_in_scope(s.orelse))
|
|
304
|
+
if s.finalbody:
|
|
305
|
+
result.extend(_walk_stmts_in_scope(s.finalbody))
|
|
306
|
+
elif isinstance(s, (ast.With, ast.AsyncWith)):
|
|
307
|
+
result.extend(_walk_stmts_in_scope(s.body))
|
|
308
|
+
elif hasattr(ast, "Match") and isinstance(s, ast.Match):
|
|
309
|
+
for case in s.cases:
|
|
310
|
+
result.extend(_walk_stmts_in_scope(case.body))
|
|
311
|
+
return result
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _has_logging_import(tree: ast.AST) -> bool:
|
|
315
|
+
for node in ast.walk(tree):
|
|
316
|
+
if isinstance(node, ast.Import):
|
|
317
|
+
for alias in node.names:
|
|
318
|
+
if alias.name == "logging":
|
|
319
|
+
return True
|
|
320
|
+
elif isinstance(node, ast.ImportFrom):
|
|
321
|
+
if node.module and (
|
|
322
|
+
node.module == "logging" or node.module.startswith("logging.")
|
|
323
|
+
):
|
|
324
|
+
return True
|
|
325
|
+
return False
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _detect_unused_locals_in_function(
|
|
329
|
+
func_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
330
|
+
filepath: str,
|
|
331
|
+
) -> list[DeadCodeItem]:
|
|
332
|
+
items: list[DeadCodeItem] = []
|
|
333
|
+
args_set: set[str] = set()
|
|
334
|
+
for a in func_node.args.posonlyargs:
|
|
335
|
+
args_set.add(a.arg)
|
|
336
|
+
for a in func_node.args.args:
|
|
337
|
+
args_set.add(a.arg)
|
|
338
|
+
for a in func_node.args.kwonlyargs:
|
|
339
|
+
args_set.add(a.arg)
|
|
340
|
+
if func_node.args.vararg:
|
|
341
|
+
args_set.add(func_node.args.vararg.arg)
|
|
342
|
+
if func_node.args.kwarg:
|
|
343
|
+
args_set.add(func_node.args.kwarg.arg)
|
|
344
|
+
|
|
345
|
+
explicit_globals: set[str] = set()
|
|
346
|
+
has_dynamic = False
|
|
347
|
+
loaded_names: set[str] = set()
|
|
348
|
+
|
|
349
|
+
for sub in ast.walk(func_node):
|
|
350
|
+
if isinstance(sub, (ast.Global, ast.Nonlocal)):
|
|
351
|
+
explicit_globals.update(sub.names)
|
|
352
|
+
elif isinstance(sub, ast.Name) and isinstance(sub.ctx, ast.Load):
|
|
353
|
+
loaded_names.add(sub.id)
|
|
354
|
+
elif isinstance(sub, ast.Call):
|
|
355
|
+
func = sub.func
|
|
356
|
+
if isinstance(func, ast.Name) and func.id in (
|
|
357
|
+
"locals",
|
|
358
|
+
"vars",
|
|
359
|
+
"eval",
|
|
360
|
+
"exec",
|
|
361
|
+
):
|
|
362
|
+
has_dynamic = True
|
|
363
|
+
|
|
364
|
+
if has_dynamic:
|
|
365
|
+
return items
|
|
366
|
+
|
|
367
|
+
stmts_in_scope = _walk_stmts_in_scope(func_node.body)
|
|
368
|
+
seen_unused: set[str] = set()
|
|
369
|
+
for stmt, _ in stmts_in_scope:
|
|
370
|
+
if isinstance(stmt, ast.Assign):
|
|
371
|
+
for target in stmt.targets:
|
|
372
|
+
if isinstance(target, ast.Name):
|
|
373
|
+
name = target.id
|
|
374
|
+
if (
|
|
375
|
+
not name.startswith("_")
|
|
376
|
+
and name not in args_set
|
|
377
|
+
and name not in explicit_globals
|
|
378
|
+
and name not in loaded_names
|
|
379
|
+
and name not in seen_unused
|
|
380
|
+
and not _is_protected_variable(name, stmt.value)
|
|
381
|
+
):
|
|
382
|
+
seen_unused.add(name)
|
|
383
|
+
items.append(
|
|
384
|
+
DeadCodeItem(
|
|
385
|
+
filepath=filepath,
|
|
386
|
+
lineno=target.lineno,
|
|
387
|
+
end_lineno=getattr(target, "end_lineno", target.lineno),
|
|
388
|
+
name=name,
|
|
389
|
+
kind="variable",
|
|
390
|
+
reason=f"Local variable '{name}' is assigned in '{func_node.name}' but never used",
|
|
391
|
+
confidence="high",
|
|
392
|
+
)
|
|
393
|
+
)
|
|
394
|
+
elif isinstance(target, (ast.Tuple, ast.List)):
|
|
395
|
+
for elt in target.elts:
|
|
396
|
+
if isinstance(elt, ast.Name):
|
|
397
|
+
name = elt.id
|
|
398
|
+
if (
|
|
399
|
+
not name.startswith("_")
|
|
400
|
+
and name not in args_set
|
|
401
|
+
and name not in explicit_globals
|
|
402
|
+
and name not in loaded_names
|
|
403
|
+
and name not in seen_unused
|
|
404
|
+
and not _is_protected_variable(name, stmt.value)
|
|
405
|
+
):
|
|
406
|
+
seen_unused.add(name)
|
|
407
|
+
items.append(
|
|
408
|
+
DeadCodeItem(
|
|
409
|
+
filepath=filepath,
|
|
410
|
+
lineno=elt.lineno,
|
|
411
|
+
end_lineno=getattr(
|
|
412
|
+
elt, "end_lineno", elt.lineno
|
|
413
|
+
),
|
|
414
|
+
name=name,
|
|
415
|
+
kind="variable",
|
|
416
|
+
reason=f"Local variable '{name}' is assigned in '{func_node.name}' but never used",
|
|
417
|
+
confidence="high",
|
|
418
|
+
)
|
|
419
|
+
)
|
|
420
|
+
elif isinstance(stmt, ast.AnnAssign):
|
|
421
|
+
if isinstance(stmt.target, ast.Name):
|
|
422
|
+
name = stmt.target.id
|
|
423
|
+
if (
|
|
424
|
+
not name.startswith("_")
|
|
425
|
+
and name not in args_set
|
|
426
|
+
and name not in explicit_globals
|
|
427
|
+
and name not in loaded_names
|
|
428
|
+
and name not in seen_unused
|
|
429
|
+
and not _is_protected_variable(name, stmt.value)
|
|
430
|
+
):
|
|
431
|
+
seen_unused.add(name)
|
|
432
|
+
items.append(
|
|
433
|
+
DeadCodeItem(
|
|
434
|
+
filepath=filepath,
|
|
435
|
+
lineno=stmt.target.lineno,
|
|
436
|
+
end_lineno=getattr(
|
|
437
|
+
stmt.target, "end_lineno", stmt.target.lineno
|
|
438
|
+
),
|
|
439
|
+
name=name,
|
|
440
|
+
kind="variable",
|
|
441
|
+
reason=f"Local variable '{name}' is assigned in '{func_node.name}' but never used",
|
|
442
|
+
confidence="high",
|
|
443
|
+
)
|
|
444
|
+
)
|
|
445
|
+
return items
|
|
446
|
+
|
|
447
|
+
|
|
204
448
|
class _UnreachableCodeDetector(ast.NodeVisitor):
|
|
205
449
|
"""Detects code after unconditional return/raise/break/continue and empty branches."""
|
|
206
450
|
|
|
@@ -215,6 +459,7 @@ class _UnreachableCodeDetector(ast.NodeVisitor):
|
|
|
215
459
|
|
|
216
460
|
def visit_FunctionDef(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
|
|
217
461
|
self._check_body(node.body)
|
|
462
|
+
self.items.extend(_detect_unused_locals_in_function(node, self.filepath))
|
|
218
463
|
self.generic_visit(node)
|
|
219
464
|
|
|
220
465
|
visit_AsyncFunctionDef = visit_FunctionDef
|
|
@@ -475,16 +720,13 @@ class DeadCodeDetector:
|
|
|
475
720
|
)
|
|
476
721
|
self.ignore_names = ignore_names or set()
|
|
477
722
|
|
|
478
|
-
def
|
|
479
|
-
|
|
480
|
-
) -> DeadCodeReport:
|
|
481
|
-
"""Scan an entire project directory for dead code."""
|
|
482
|
-
root = Path(root_dir).resolve()
|
|
483
|
-
py_files = self._discover_files(root, exclude_patterns=exclude_patterns)
|
|
484
|
-
|
|
723
|
+
def scan_files(self, target_files: Sequence[Path]) -> DeadCodeReport:
|
|
724
|
+
"""Scan a specific sequence of Python files for dead code without walking parent directories."""
|
|
485
725
|
state = _ProjectScanState(framework_registry=self.framework_registry)
|
|
486
|
-
|
|
726
|
+
scanned_count = 0
|
|
727
|
+
for py_file in target_files:
|
|
487
728
|
state.process_file(py_file)
|
|
729
|
+
scanned_count += 1
|
|
488
730
|
|
|
489
731
|
items: list[DeadCodeItem] = list(state.unreachable)
|
|
490
732
|
for filepath, name, kind, lineno, end_lineno, _ in state.definitions:
|
|
@@ -507,10 +749,18 @@ class DeadCodeDetector:
|
|
|
507
749
|
items.sort(key=lambda x: (x.filepath, x.lineno))
|
|
508
750
|
return DeadCodeReport(
|
|
509
751
|
items=items,
|
|
510
|
-
files_scanned=
|
|
752
|
+
files_scanned=scanned_count,
|
|
511
753
|
total_definitions=len(state.definitions),
|
|
512
754
|
)
|
|
513
755
|
|
|
756
|
+
def scan_project(
|
|
757
|
+
self, root_dir: Path | str, exclude_patterns: Sequence[str] = ()
|
|
758
|
+
) -> DeadCodeReport:
|
|
759
|
+
"""Scan an entire project directory for dead code."""
|
|
760
|
+
root = Path(root_dir).resolve()
|
|
761
|
+
py_files = self._discover_files(root, exclude_patterns=exclude_patterns)
|
|
762
|
+
return self.scan_files(py_files)
|
|
763
|
+
|
|
514
764
|
def scan_source(self, source: str, filename: str = "<unknown>") -> DeadCodeReport:
|
|
515
765
|
"""Scan a single source string for dead code patterns (unreachable/empty only)."""
|
|
516
766
|
try:
|
|
@@ -571,26 +821,38 @@ class DeadCodeDetector:
|
|
|
571
821
|
def fix_file(
|
|
572
822
|
self, filepath: Path | str, apply_changes: bool = True
|
|
573
823
|
) -> DeadCodeFixResult:
|
|
574
|
-
"""Surgically fix dead code in a file."""
|
|
824
|
+
"""Surgically fix dead code in a file with safe read-only handling."""
|
|
575
825
|
path = Path(filepath).resolve()
|
|
576
|
-
|
|
826
|
+
try:
|
|
827
|
+
content = path.read_text(encoding="utf-8", errors="replace")
|
|
828
|
+
except OSError:
|
|
829
|
+
return DeadCodeFixResult(code="", changed=False, pruned_items=[])
|
|
577
830
|
res = self.fix_source(content, filename=str(path))
|
|
578
831
|
if apply_changes and res.changed:
|
|
579
|
-
|
|
832
|
+
try:
|
|
833
|
+
path.write_text(res.code, encoding="utf-8")
|
|
834
|
+
except OSError:
|
|
835
|
+
return DeadCodeFixResult(code=content, changed=False, pruned_items=[])
|
|
580
836
|
return res
|
|
581
837
|
|
|
838
|
+
def fix_files(
|
|
839
|
+
self, target_files: Sequence[Path], apply_changes: bool = True
|
|
840
|
+
) -> dict[Path, DeadCodeFixResult]:
|
|
841
|
+
"""Surgically fix dead code across a specific sequence of Python files."""
|
|
842
|
+
results: dict[Path, DeadCodeFixResult] = {}
|
|
843
|
+
for pf in target_files:
|
|
844
|
+
res = self.fix_file(pf, apply_changes=apply_changes)
|
|
845
|
+
if res.changed:
|
|
846
|
+
results[pf] = res
|
|
847
|
+
return results
|
|
848
|
+
|
|
582
849
|
def fix_project(
|
|
583
850
|
self, root_dir: Path | str, exclude_patterns: Sequence[str] = ()
|
|
584
851
|
) -> dict[Path, DeadCodeFixResult]:
|
|
585
852
|
"""Surgically fix dead code across all project Python files."""
|
|
586
853
|
root = Path(root_dir).resolve()
|
|
587
854
|
py_files = self._discover_files(root, exclude_patterns=exclude_patterns)
|
|
588
|
-
|
|
589
|
-
for pf in py_files:
|
|
590
|
-
res = self.fix_file(pf, apply_changes=True)
|
|
591
|
-
if res.changed:
|
|
592
|
-
results[pf] = res
|
|
593
|
-
return results
|
|
855
|
+
return self.fix_files(py_files, apply_changes=True)
|
|
594
856
|
|
|
595
857
|
|
|
596
858
|
@dataclass(slots=True)
|
|
@@ -721,38 +983,396 @@ class _DeadCodePrunerCollector(ast.NodeVisitor):
|
|
|
721
983
|
|
|
722
984
|
|
|
723
985
|
class DeadCodeFixer:
|
|
724
|
-
"""Surgically eliminates unreachable statements, dead branches, and
|
|
986
|
+
"""Surgically eliminates unreachable statements, dead branches, heals empty except blocks, and fixes unused variables."""
|
|
725
987
|
|
|
726
988
|
def fix(self, source: str, filename: str = "<stdin>") -> DeadCodeFixResult:
|
|
727
989
|
current_code = source
|
|
728
990
|
all_pruned: list[str] = []
|
|
729
991
|
|
|
730
|
-
for _ in range(
|
|
992
|
+
for _ in range(3):
|
|
993
|
+
changed_this_pass = False
|
|
994
|
+
|
|
995
|
+
# --- Pass 1: Empty Except Healer ---
|
|
731
996
|
try:
|
|
732
997
|
tree = ast.parse(current_code, filename=filename)
|
|
733
998
|
except SyntaxError:
|
|
734
999
|
break
|
|
735
1000
|
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
1001
|
+
except_handlers_to_heal: list[tuple[int, int]] = []
|
|
1002
|
+
for node in ast.walk(tree):
|
|
1003
|
+
if isinstance(node, ast.ExceptHandler):
|
|
1004
|
+
if len(node.body) == 1 and isinstance(node.body[0], ast.Pass):
|
|
1005
|
+
pass_node = node.body[0]
|
|
1006
|
+
except_handlers_to_heal.append((node.lineno, pass_node.lineno))
|
|
1007
|
+
|
|
1008
|
+
if except_handlers_to_heal:
|
|
1009
|
+
lines = current_code.splitlines(keepends=True)
|
|
1010
|
+
needs_logging_import = not _has_logging_import(tree)
|
|
1011
|
+
|
|
1012
|
+
# Sort by line number descending so replacements do not shift earlier lines
|
|
1013
|
+
for handler_line, pass_line in sorted(
|
|
1014
|
+
except_handlers_to_heal, key=lambda x: x[1], reverse=True
|
|
1015
|
+
):
|
|
1016
|
+
idx = pass_line - 1
|
|
1017
|
+
if 0 <= idx < len(lines):
|
|
1018
|
+
orig_line = lines[idx]
|
|
1019
|
+
indent = orig_line[: len(orig_line) - len(orig_line.lstrip())]
|
|
1020
|
+
lines[idx] = (
|
|
1021
|
+
f"{indent}logging.getLogger(__name__).debug("
|
|
1022
|
+
f"'Suppressed exception', exc_info=True)\n"
|
|
1023
|
+
)
|
|
1024
|
+
all_pruned.append(
|
|
1025
|
+
f"Healed empty except block at line {handler_line} with debug logging"
|
|
1026
|
+
)
|
|
1027
|
+
changed_this_pass = True
|
|
1028
|
+
|
|
1029
|
+
if needs_logging_import and changed_this_pass:
|
|
1030
|
+
insert_idx = 0
|
|
1031
|
+
while insert_idx < len(lines) and (
|
|
1032
|
+
lines[insert_idx].startswith("#!")
|
|
1033
|
+
or "coding:" in lines[insert_idx]
|
|
1034
|
+
or "coding=" in lines[insert_idx]
|
|
1035
|
+
):
|
|
1036
|
+
insert_idx += 1
|
|
1037
|
+
|
|
1038
|
+
doc_node = (
|
|
1039
|
+
tree.body[0]
|
|
1040
|
+
if tree.body
|
|
1041
|
+
and isinstance(tree.body[0], ast.Expr)
|
|
1042
|
+
and isinstance(tree.body[0].value, ast.Constant)
|
|
1043
|
+
and isinstance(tree.body[0].value.value, str)
|
|
1044
|
+
else None
|
|
1045
|
+
)
|
|
1046
|
+
if (
|
|
1047
|
+
doc_node
|
|
1048
|
+
and hasattr(doc_node, "end_lineno")
|
|
1049
|
+
and doc_node.end_lineno is not None
|
|
1050
|
+
):
|
|
1051
|
+
insert_idx = max(insert_idx, doc_node.end_lineno)
|
|
1052
|
+
|
|
1053
|
+
for stmt in tree.body:
|
|
1054
|
+
if (
|
|
1055
|
+
isinstance(stmt, ast.ImportFrom)
|
|
1056
|
+
and stmt.module == "__future__"
|
|
1057
|
+
and hasattr(stmt, "end_lineno")
|
|
1058
|
+
and stmt.end_lineno is not None
|
|
1059
|
+
):
|
|
1060
|
+
insert_idx = max(insert_idx, stmt.end_lineno)
|
|
1061
|
+
|
|
1062
|
+
lines.insert(insert_idx, "import logging\n")
|
|
1063
|
+
all_pruned.append("Added 'import logging' for healed except block")
|
|
1064
|
+
|
|
1065
|
+
candidate = "".join(lines)
|
|
1066
|
+
try:
|
|
1067
|
+
ast.parse(candidate, filename=filename)
|
|
1068
|
+
current_code = candidate
|
|
1069
|
+
except SyntaxError:
|
|
1070
|
+
logging.getLogger(__name__).debug(
|
|
1071
|
+
"Suppressed exception", exc_info=True
|
|
1072
|
+
)
|
|
1073
|
+
|
|
1074
|
+
# --- Pass 2: Dead Variable Fixer ---
|
|
1075
|
+
try:
|
|
1076
|
+
tree = ast.parse(current_code, filename=filename)
|
|
1077
|
+
except SyntaxError:
|
|
739
1078
|
break
|
|
740
1079
|
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
for
|
|
746
|
-
|
|
747
|
-
|
|
1080
|
+
renames: list[tuple[int, int, int, str, str]] = []
|
|
1081
|
+
deletions: list[tuple[int, int, list[ast.stmt], str]] = []
|
|
1082
|
+
|
|
1083
|
+
# 2a. Function-level local variables
|
|
1084
|
+
for node in ast.walk(tree):
|
|
1085
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
1086
|
+
args_set: set[str] = set()
|
|
1087
|
+
for a in node.args.posonlyargs:
|
|
1088
|
+
args_set.add(a.arg)
|
|
1089
|
+
for a in node.args.args:
|
|
1090
|
+
args_set.add(a.arg)
|
|
1091
|
+
for a in node.args.kwonlyargs:
|
|
1092
|
+
args_set.add(a.arg)
|
|
1093
|
+
if node.args.vararg:
|
|
1094
|
+
args_set.add(node.args.vararg.arg)
|
|
1095
|
+
if node.args.kwarg:
|
|
1096
|
+
args_set.add(node.args.kwarg.arg)
|
|
1097
|
+
|
|
1098
|
+
explicit_globals: set[str] = set()
|
|
1099
|
+
has_dynamic = False
|
|
1100
|
+
loaded_names: set[str] = set()
|
|
1101
|
+
|
|
1102
|
+
for sub in ast.walk(node):
|
|
1103
|
+
if isinstance(sub, (ast.Global, ast.Nonlocal)):
|
|
1104
|
+
explicit_globals.update(sub.names)
|
|
1105
|
+
elif isinstance(sub, ast.Name) and isinstance(
|
|
1106
|
+
sub.ctx, ast.Load
|
|
1107
|
+
):
|
|
1108
|
+
loaded_names.add(sub.id)
|
|
1109
|
+
elif isinstance(sub, ast.Call):
|
|
1110
|
+
func = sub.func
|
|
1111
|
+
if isinstance(func, ast.Name) and func.id in (
|
|
1112
|
+
"locals",
|
|
1113
|
+
"vars",
|
|
1114
|
+
"eval",
|
|
1115
|
+
"exec",
|
|
1116
|
+
):
|
|
1117
|
+
has_dynamic = True
|
|
1118
|
+
|
|
1119
|
+
if has_dynamic:
|
|
1120
|
+
continue
|
|
1121
|
+
|
|
1122
|
+
stmts_in_scope = _walk_stmts_in_scope(node.body)
|
|
1123
|
+
for stmt, parent_body in stmts_in_scope:
|
|
1124
|
+
if isinstance(stmt, ast.Assign):
|
|
1125
|
+
if len(stmt.targets) == 1 and isinstance(
|
|
1126
|
+
stmt.targets[0], ast.Name
|
|
1127
|
+
):
|
|
1128
|
+
target = stmt.targets[0]
|
|
1129
|
+
name = target.id
|
|
1130
|
+
if (
|
|
1131
|
+
not name.startswith("_")
|
|
1132
|
+
and name not in args_set
|
|
1133
|
+
and name not in explicit_globals
|
|
1134
|
+
and name not in loaded_names
|
|
1135
|
+
and not _is_protected_variable(name, stmt.value)
|
|
1136
|
+
):
|
|
1137
|
+
if _is_pure_expression(stmt.value):
|
|
1138
|
+
deletions.append(
|
|
1139
|
+
(
|
|
1140
|
+
stmt.lineno,
|
|
1141
|
+
getattr(
|
|
1142
|
+
stmt, "end_lineno", stmt.lineno
|
|
1143
|
+
),
|
|
1144
|
+
parent_body,
|
|
1145
|
+
name,
|
|
1146
|
+
)
|
|
1147
|
+
)
|
|
1148
|
+
else:
|
|
1149
|
+
col = getattr(target, "col_offset", None)
|
|
1150
|
+
end_col = getattr(
|
|
1151
|
+
target, "end_col_offset", None
|
|
1152
|
+
)
|
|
1153
|
+
if isinstance(col, int) and isinstance(
|
|
1154
|
+
end_col, int
|
|
1155
|
+
):
|
|
1156
|
+
renames.append(
|
|
1157
|
+
(
|
|
1158
|
+
target.lineno,
|
|
1159
|
+
col,
|
|
1160
|
+
end_col,
|
|
1161
|
+
f"_{name}",
|
|
1162
|
+
name,
|
|
1163
|
+
)
|
|
1164
|
+
)
|
|
1165
|
+
elif len(stmt.targets) == 1 and isinstance(
|
|
1166
|
+
stmt.targets[0], (ast.Tuple, ast.List)
|
|
1167
|
+
):
|
|
1168
|
+
for elt in stmt.targets[0].elts:
|
|
1169
|
+
if isinstance(elt, ast.Name):
|
|
1170
|
+
name = elt.id
|
|
1171
|
+
col = getattr(elt, "col_offset", None)
|
|
1172
|
+
end_col = getattr(elt, "end_col_offset", None)
|
|
1173
|
+
if (
|
|
1174
|
+
not name.startswith("_")
|
|
1175
|
+
and name not in args_set
|
|
1176
|
+
and name not in explicit_globals
|
|
1177
|
+
and name not in loaded_names
|
|
1178
|
+
and not _is_protected_variable(
|
|
1179
|
+
name, stmt.value
|
|
1180
|
+
)
|
|
1181
|
+
and isinstance(col, int)
|
|
1182
|
+
and isinstance(end_col, int)
|
|
1183
|
+
):
|
|
1184
|
+
renames.append(
|
|
1185
|
+
(
|
|
1186
|
+
elt.lineno,
|
|
1187
|
+
col,
|
|
1188
|
+
end_col,
|
|
1189
|
+
f"_{name}",
|
|
1190
|
+
name,
|
|
1191
|
+
)
|
|
1192
|
+
)
|
|
1193
|
+
elif isinstance(stmt, ast.AnnAssign):
|
|
1194
|
+
if isinstance(stmt.target, ast.Name):
|
|
1195
|
+
name = stmt.target.id
|
|
1196
|
+
if (
|
|
1197
|
+
not name.startswith("_")
|
|
1198
|
+
and name not in args_set
|
|
1199
|
+
and name not in explicit_globals
|
|
1200
|
+
and name not in loaded_names
|
|
1201
|
+
and not _is_protected_variable(name, stmt.value)
|
|
1202
|
+
):
|
|
1203
|
+
if stmt.value is not None and _is_pure_expression(
|
|
1204
|
+
stmt.value
|
|
1205
|
+
):
|
|
1206
|
+
deletions.append(
|
|
1207
|
+
(
|
|
1208
|
+
stmt.lineno,
|
|
1209
|
+
getattr(
|
|
1210
|
+
stmt, "end_lineno", stmt.lineno
|
|
1211
|
+
),
|
|
1212
|
+
parent_body,
|
|
1213
|
+
name,
|
|
1214
|
+
)
|
|
1215
|
+
)
|
|
1216
|
+
else:
|
|
1217
|
+
target = stmt.target
|
|
1218
|
+
col = getattr(target, "col_offset", None)
|
|
1219
|
+
end_col = getattr(
|
|
1220
|
+
target, "end_col_offset", None
|
|
1221
|
+
)
|
|
1222
|
+
if isinstance(col, int) and isinstance(
|
|
1223
|
+
end_col, int
|
|
1224
|
+
):
|
|
1225
|
+
renames.append(
|
|
1226
|
+
(
|
|
1227
|
+
target.lineno,
|
|
1228
|
+
col,
|
|
1229
|
+
end_col,
|
|
1230
|
+
f"_{name}",
|
|
1231
|
+
name,
|
|
1232
|
+
)
|
|
1233
|
+
)
|
|
1234
|
+
|
|
1235
|
+
# 2b. Module-level script variables (when not an __init__.py package export)
|
|
1236
|
+
if not filename.endswith("__init__.py"):
|
|
1237
|
+
module_loads: set[str] = set()
|
|
1238
|
+
module_exports: set[str] = set()
|
|
1239
|
+
for sub in ast.walk(tree):
|
|
1240
|
+
if isinstance(sub, ast.Name) and isinstance(sub.ctx, ast.Load):
|
|
1241
|
+
module_loads.add(sub.id)
|
|
1242
|
+
elif (
|
|
1243
|
+
isinstance(sub, ast.Assign)
|
|
1244
|
+
and any(
|
|
1245
|
+
isinstance(t, ast.Name) and t.id == "__all__"
|
|
1246
|
+
for t in sub.targets
|
|
1247
|
+
)
|
|
1248
|
+
and isinstance(sub.value, (ast.List, ast.Tuple, ast.Set))
|
|
1249
|
+
):
|
|
1250
|
+
for elt in sub.value.elts:
|
|
1251
|
+
if isinstance(elt, ast.Constant) and isinstance(
|
|
1252
|
+
elt.value, str
|
|
1253
|
+
):
|
|
1254
|
+
module_exports.add(elt.value)
|
|
1255
|
+
|
|
1256
|
+
for stmt in tree.body:
|
|
1257
|
+
if (
|
|
1258
|
+
isinstance(stmt, ast.Assign)
|
|
1259
|
+
and len(stmt.targets) == 1
|
|
1260
|
+
and isinstance(stmt.targets[0], ast.Name)
|
|
1261
|
+
):
|
|
1262
|
+
target = stmt.targets[0]
|
|
1263
|
+
name = target.id
|
|
1264
|
+
if (
|
|
1265
|
+
not name.startswith("_")
|
|
1266
|
+
and name != "__all__"
|
|
1267
|
+
and name not in module_loads
|
|
1268
|
+
and name not in module_exports
|
|
1269
|
+
and not _is_protected_variable(name, stmt.value)
|
|
1270
|
+
):
|
|
1271
|
+
if _is_pure_expression(stmt.value):
|
|
1272
|
+
deletions.append(
|
|
1273
|
+
(
|
|
1274
|
+
stmt.lineno,
|
|
1275
|
+
getattr(stmt, "end_lineno", stmt.lineno),
|
|
1276
|
+
tree.body,
|
|
1277
|
+
name,
|
|
1278
|
+
)
|
|
1279
|
+
)
|
|
1280
|
+
else:
|
|
1281
|
+
col = getattr(target, "col_offset", None)
|
|
1282
|
+
end_col = getattr(target, "end_col_offset", None)
|
|
1283
|
+
if isinstance(col, int) and isinstance(end_col, int):
|
|
1284
|
+
renames.append(
|
|
1285
|
+
(
|
|
1286
|
+
target.lineno,
|
|
1287
|
+
col,
|
|
1288
|
+
end_col,
|
|
1289
|
+
f"_{name}",
|
|
1290
|
+
name,
|
|
1291
|
+
)
|
|
1292
|
+
)
|
|
1293
|
+
|
|
1294
|
+
if renames or deletions:
|
|
1295
|
+
lines = current_code.splitlines(keepends=True)
|
|
1296
|
+
|
|
1297
|
+
# 1. Apply renames line-by-line, ordered by column descending
|
|
1298
|
+
renames_by_line: dict[int, list[tuple[int, int, str, str]]] = {}
|
|
1299
|
+
for lineno, col, end_col, new_text, name in renames:
|
|
1300
|
+
renames_by_line.setdefault(lineno, []).append(
|
|
1301
|
+
(col, end_col, new_text, name)
|
|
1302
|
+
)
|
|
1303
|
+
|
|
1304
|
+
for lineno, line_renames in renames_by_line.items():
|
|
1305
|
+
idx = lineno - 1
|
|
1306
|
+
if 0 <= idx < len(lines):
|
|
1307
|
+
line_str = lines[idx]
|
|
1308
|
+
for col, end_col, new_text, name in sorted(
|
|
1309
|
+
line_renames, key=lambda x: x[0], reverse=True
|
|
1310
|
+
):
|
|
1311
|
+
line_str = line_str[:col] + new_text + line_str[end_col:]
|
|
1312
|
+
all_pruned.append(
|
|
1313
|
+
f"Prefixed unused variable '{name}' with '_' at line {lineno} to preserve side effects"
|
|
1314
|
+
)
|
|
1315
|
+
changed_this_pass = True
|
|
1316
|
+
lines[idx] = line_str
|
|
1317
|
+
|
|
1318
|
+
# 2. Apply deletions ordered by start line descending
|
|
1319
|
+
for start_line, end_line, parent_body, name in sorted(
|
|
1320
|
+
deletions, key=lambda x: x[0], reverse=True
|
|
1321
|
+
):
|
|
1322
|
+
start_idx = start_line - 1
|
|
1323
|
+
if 0 <= start_idx < len(lines):
|
|
1324
|
+
if len(parent_body) == 1:
|
|
1325
|
+
orig_line = lines[start_idx]
|
|
1326
|
+
indent = orig_line[
|
|
1327
|
+
: len(orig_line) - len(orig_line.lstrip())
|
|
1328
|
+
]
|
|
1329
|
+
lines[start_idx:end_line] = [f"{indent}pass\n"]
|
|
1330
|
+
else:
|
|
1331
|
+
del lines[start_idx:end_line]
|
|
1332
|
+
all_pruned.append(
|
|
1333
|
+
f"Pruned unused local variable '{name}' assignment at line {start_line}"
|
|
1334
|
+
)
|
|
1335
|
+
changed_this_pass = True
|
|
1336
|
+
|
|
1337
|
+
candidate = "".join(lines)
|
|
1338
|
+
try:
|
|
1339
|
+
ast.parse(candidate, filename=filename)
|
|
1340
|
+
current_code = candidate
|
|
1341
|
+
except SyntaxError:
|
|
1342
|
+
logging.getLogger(__name__).debug(
|
|
1343
|
+
"Suppressed exception", exc_info=True
|
|
1344
|
+
)
|
|
748
1345
|
|
|
749
|
-
|
|
1346
|
+
# --- Pass 3: Unreachable Code & Redundant Pass ---
|
|
750
1347
|
try:
|
|
751
|
-
ast.parse(
|
|
752
|
-
current_code = candidate
|
|
1348
|
+
tree = ast.parse(current_code, filename=filename)
|
|
753
1349
|
except SyntaxError:
|
|
754
1350
|
break
|
|
755
1351
|
|
|
1352
|
+
collector = _DeadCodePrunerCollector()
|
|
1353
|
+
collector.visit(tree)
|
|
1354
|
+
if collector.deletions:
|
|
1355
|
+
lines = current_code.splitlines(keepends=True)
|
|
1356
|
+
sorted_deletions = sorted(
|
|
1357
|
+
collector.deletions, key=lambda x: x[0], reverse=True
|
|
1358
|
+
)
|
|
1359
|
+
for start_line, end_line, desc in sorted_deletions:
|
|
1360
|
+
del lines[start_line - 1 : end_line]
|
|
1361
|
+
all_pruned.append(desc)
|
|
1362
|
+
changed_this_pass = True
|
|
1363
|
+
|
|
1364
|
+
candidate = "".join(lines)
|
|
1365
|
+
try:
|
|
1366
|
+
ast.parse(candidate, filename=filename)
|
|
1367
|
+
current_code = candidate
|
|
1368
|
+
except SyntaxError:
|
|
1369
|
+
logging.getLogger(__name__).debug(
|
|
1370
|
+
"Suppressed exception", exc_info=True
|
|
1371
|
+
)
|
|
1372
|
+
|
|
1373
|
+
if not changed_this_pass:
|
|
1374
|
+
break
|
|
1375
|
+
|
|
756
1376
|
return DeadCodeFixResult(
|
|
757
1377
|
code=current_code,
|
|
758
1378
|
changed=current_code != source,
|