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.
@@ -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 scan_project(
479
- self, root_dir: Path | str, exclude_patterns: Sequence[str] = ()
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
- for py_file in py_files:
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=len(py_files),
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
- content = path.read_text(encoding="utf-8", errors="replace")
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
- path.write_text(res.code, encoding="utf-8")
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
- results: dict[Path, DeadCodeFixResult] = {}
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 redundant pass statements."""
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(2):
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
- collector = _DeadCodePrunerCollector()
737
- collector.visit(tree)
738
- if not collector.deletions:
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
- lines = current_code.splitlines(keepends=True)
742
- sorted_deletions = sorted(
743
- collector.deletions, key=lambda x: x[0], reverse=True
744
- )
745
- for start_line, end_line, desc in sorted_deletions:
746
- del lines[start_line - 1 : end_line]
747
- all_pruned.append(desc)
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
- candidate = "".join(lines)
1346
+ # --- Pass 3: Unreachable Code & Redundant Pass ---
750
1347
  try:
751
- ast.parse(candidate, filename=filename)
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,