dead-cst 0.1.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.
dead_cst/__init__.py ADDED
@@ -0,0 +1,99 @@
1
+ """Dead code analysis for Python using LibCST.
2
+
3
+ `dead-cst` builds a symbol-level reachability graph of a Python codebase and
4
+ reports (or removes) anything not reachable from a configurable set of
5
+ entrypoints.
6
+
7
+ The public API has three layers:
8
+
9
+ * :func:`build_symbol_graph` parses every ``.py`` file under each base in a
10
+ ``{base: [dep_paths]}`` map and returns a :class:`networkx.DiGraph` of
11
+ :class:`SymbolNode` instances. Edges encode "keeps alive" relationships.
12
+ * Edge plugins (:class:`MainBlockPlugin`, :class:`ProjectScriptsPlugin`,
13
+ :class:`ExplicitEntrypointPlugin`, :class:`ModuleDundersPlugin`,
14
+ :class:`PytestPlugin`, :class:`UnittestPlugin`, :class:`FastAPIPlugin`,
15
+ :class:`FlaskPlugin`, :class:`TyperPlugin`, :class:`ClickPlugin`)
16
+ extend the graph with edges that pure CST analysis can't infer --
17
+ entry points, framework conventions, dynamic dispatch. Custom
18
+ plugins implement the :class:`EdgePlugin` protocol.
19
+ * Path resolvers (:class:`VenvResolver`, :class:`PyprojectResolver`,
20
+ :class:`UvWorkspaceResolver`) discover the ``{base: [dep_paths]}`` map
21
+ itself from a project root, so callers don't have to hand-build it.
22
+
23
+ After plugins run, :func:`find_reachable` walks successors from every node
24
+ tagged with ``entrypoint=True`` and returns the live set;
25
+ :func:`remove_code` rewrites source files in place to delete the rest via
26
+ a LibCST codemod that preserves surrounding formatting.
27
+
28
+ See the README for the CLI, the entrypoint and search-path specs, and the
29
+ limitations of static analysis.
30
+ """
31
+
32
+ from ._analyze import build_symbol_graph, count_nodes, find_reachable, order_paths
33
+ from ._codemod import remove_code
34
+ from ._plugins import (
35
+ AddEdge,
36
+ AddNode,
37
+ BUILTIN_PLUGINS,
38
+ ClickPlugin,
39
+ EdgePlugin,
40
+ ExplicitEntrypointPlugin,
41
+ FastAPIPlugin,
42
+ FlaskPlugin,
43
+ GraphOp,
44
+ InitSubclassPlugin,
45
+ MainBlockPlugin,
46
+ ModuleDundersPlugin,
47
+ PluginContext,
48
+ ProjectScriptsPlugin,
49
+ PytestPlugin,
50
+ RemoveEdge,
51
+ TyperPlugin,
52
+ UnittestPlugin,
53
+ load_plugin,
54
+ )
55
+ from ._resolvers import (
56
+ BUILTIN_RESOLVERS,
57
+ PathResolver,
58
+ PyprojectResolver,
59
+ UvWorkspaceResolver,
60
+ VenvResolver,
61
+ load_resolver,
62
+ merge_paths,
63
+ )
64
+ from ._version import __version__
65
+
66
+ __all__ = [
67
+ "__version__",
68
+ "AddEdge",
69
+ "AddNode",
70
+ "BUILTIN_PLUGINS",
71
+ "BUILTIN_RESOLVERS",
72
+ "ClickPlugin",
73
+ "EdgePlugin",
74
+ "ExplicitEntrypointPlugin",
75
+ "FastAPIPlugin",
76
+ "FlaskPlugin",
77
+ "GraphOp",
78
+ "InitSubclassPlugin",
79
+ "MainBlockPlugin",
80
+ "ModuleDundersPlugin",
81
+ "PathResolver",
82
+ "PluginContext",
83
+ "ProjectScriptsPlugin",
84
+ "PyprojectResolver",
85
+ "PytestPlugin",
86
+ "RemoveEdge",
87
+ "TyperPlugin",
88
+ "UnittestPlugin",
89
+ "UvWorkspaceResolver",
90
+ "VenvResolver",
91
+ "build_symbol_graph",
92
+ "count_nodes",
93
+ "find_reachable",
94
+ "load_plugin",
95
+ "load_resolver",
96
+ "merge_paths",
97
+ "order_paths",
98
+ "remove_code",
99
+ ]
dead_cst/_analyze.py ADDED
@@ -0,0 +1,258 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from pathlib import Path
5
+ from typing import Sequence
6
+
7
+ import libcst as cst
8
+ import networkx as nx
9
+ from libcst.metadata import FullRepoManager
10
+
11
+ from ._fqn import FixedFullyQualifiedNameProvider
12
+ from ._plugins import (
13
+ EdgePlugin,
14
+ PluginContext,
15
+ apply_ops,
16
+ )
17
+ from ._resolve import resolve_edges, safe_resolve_module, temp_sys_path
18
+ from ._resolvers import PathMap, exported_roots
19
+ from ._symbols import SymbolNode, SymbolTrie
20
+ from ._visitor import SymbolVisitor
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ def order_paths(paths: PathMap) -> list[Path]:
26
+ """Topologically sort base paths so dependencies are processed first.
27
+
28
+ ``paths`` maps each base directory to the list of other base directories
29
+ it imports from (added to ``sys.path`` while it is processed). The
30
+ returned order ensures that when a base is processed every base it
31
+ depends on has already contributed its symbols to the lookup tables, so
32
+ cross-package import resolution sees them.
33
+ """
34
+ path_order = nx.DiGraph()
35
+ for base, search_paths in paths.items():
36
+ path_order.add_node(base)
37
+ for sp in search_paths:
38
+ path_order.add_edge(sp, base)
39
+ return list(nx.topological_sort(path_order))
40
+
41
+
42
+ def build_symbol_graph(
43
+ paths: PathMap,
44
+ *,
45
+ plugins: Sequence[EdgePlugin] = (),
46
+ project_root: Path | None = None,
47
+ ) -> nx.DiGraph:
48
+ """Build a directed reachability graph of every top-level symbol under ``paths``.
49
+
50
+ Each ``.py`` file under each base in ``paths`` is parsed with LibCST;
51
+ modules, classes, functions, top-level variables, and module-level imports
52
+ become :class:`SymbolNode` graph nodes. Edges encode "keeps alive"
53
+ relationships:
54
+
55
+ * a reference points at its referent,
56
+ * a declaration points at its containing module,
57
+ * a submodule points at its parent package, and
58
+ * synthetic ``unreachable`` nodes own edges into symbols referenced from
59
+ statically-dead suites (``if False:``, ``raise``-only branches, ...) so
60
+ those references don't keep the targets alive.
61
+
62
+ Third-party imports are surfaced as synthetic ``[external dist] <name>``
63
+ / ``[external file] <name>`` nodes so callers can audit the
64
+ project's dependency surface (see the ``dependencies`` CLI command).
65
+
66
+ Plugins run once per base in topological order, after that base's import
67
+ edges have been resolved. Each plugin invocation gets a per-base
68
+ :class:`PluginContext` whose parsed-module cache is primed with the
69
+ modules the analyzer just walked, so plugins never re-read or re-parse.
70
+
71
+ Parameters
72
+ ----------
73
+ paths:
74
+ Mapping from base directory to its first-party search-path
75
+ dependencies. For a single-package project, pass ``{root: []}``. For
76
+ a monorepo, list the dependencies so they're added to ``sys.path``
77
+ and resolved as first-party. ``order_paths`` orders the bases.
78
+ plugins:
79
+ Sequence of :class:`EdgePlugin` instances. Plugins emit
80
+ :class:`AddNode`, :class:`AddEdge`, and :class:`RemoveEdge` ops;
81
+ ``AddNode(..., entrypoint=True)`` seeds :func:`find_reachable`.
82
+ project_root:
83
+ Project root used by plugins for path-relative matching. If
84
+ omitted, inferred as the shortest path in ``paths``.
85
+
86
+ Returns
87
+ -------
88
+ networkx.DiGraph
89
+ Nodes are :class:`SymbolNode` instances; entrypoint seeds carry
90
+ ``graph.nodes[node]["entrypoint"] = True``.
91
+ """
92
+ symbol_graph = nx.DiGraph()
93
+ base_tries: dict[Path, SymbolTrie] = {}
94
+ export_tries: dict[Path, SymbolTrie] = {}
95
+ root = project_root or _infer_project_root(paths) if paths else Path.cwd()
96
+ for base in order_paths(paths):
97
+ logger.debug("Processing base path: %s", base)
98
+ search_paths = [base] + paths.get(base, [])
99
+ safe_resolve_module.cache_clear()
100
+ with temp_sys_path(search_paths):
101
+ base_tries[base] = current_trie = SymbolTrie()
102
+ export_trie = SymbolTrie()
103
+ export_roots = exported_roots(base)
104
+ import_edges = set()
105
+ files = list(sorted(base.rglob("*.py")))
106
+ mgr = FullRepoManager(
107
+ str(base), [str(f) for f in files], {FixedFullyQualifiedNameProvider}
108
+ )
109
+ # Stash the modules the visitor pass parses so plugins can reuse
110
+ # them via ``ctx.parse(path)`` without re-reading or re-parsing.
111
+ base_modules: dict[Path, cst.Module] | None = {} if plugins else None
112
+ for file in files:
113
+ wrapper = mgr.get_metadata_wrapper_for_path(str(file))
114
+ if base_modules is not None:
115
+ base_modules[file] = wrapper.module
116
+ visitor = SymbolVisitor(file, search_paths)
117
+ wrapper.visit(visitor)
118
+ # A file's decls go into ``export_trie`` only when the file
119
+ # lives under one of ``base``'s exported dirs (or when the
120
+ # base has no export restriction). This is what hides
121
+ # ``tests/`` from dependents in the typical workspace
122
+ # layout: the member analyzes its own ``tests/`` (decls
123
+ # land in ``current_trie`` and the graph), but consumers'
124
+ # lookup tries never see them.
125
+ file_exported = export_roots is None or _under_any(file, export_roots)
126
+ curr = [visitor.trie]
127
+ while curr:
128
+ node = curr.pop()
129
+ if node.module is not None:
130
+ symbol_graph.add_node(node.module)
131
+ current_trie.add_declaration(node.module)
132
+ if file_exported:
133
+ export_trie.add_declaration(node.module)
134
+ for decls in node.declarations.values():
135
+ for decl in decls:
136
+ symbol_graph.add_node(decl)
137
+ symbol_graph.add_edge(decl, node.module)
138
+ current_trie.add_declaration(decl)
139
+ if file_exported:
140
+ export_trie.add_declaration(decl)
141
+ # Shadowed decls still belong to this module: emit them
142
+ # so the graph stays well-formed (every decl has a
143
+ # parent-module edge). They aren't re-added to
144
+ # ``current_trie`` -- only live-at-exit decls should
145
+ # win project-wide lookups.
146
+ for decl in node.shadowed:
147
+ symbol_graph.add_node(decl)
148
+ symbol_graph.add_edge(decl, node.module)
149
+ curr.extend(node.children.values())
150
+
151
+ for src, dst in visitor.internal_edges:
152
+ symbol_graph.add_edge(src, dst)
153
+
154
+ # Synthetic ``unreachable`` nodes for statically-dead suites.
155
+ # They live in the graph as orphan sources -- nothing points
156
+ # at them, so reachability never visits them, but the edges
157
+ # they own surface which symbols are referenced from inside
158
+ # dead branches. Existing top-level decl edges are unchanged.
159
+ for unreachable in visitor.unreachable_nodes:
160
+ symbol_graph.add_node(unreachable)
161
+ for src, dst in visitor.unreachable_internal_edges:
162
+ symbol_graph.add_edge(src, dst)
163
+
164
+ import_edges |= visitor.import_edges | visitor.unreachable_import_edges
165
+
166
+ current_trie.add_module_hierarchy_edges(symbol_graph)
167
+ export_tries[base] = export_trie
168
+
169
+ # Per-consumer lookup trie: this base's full trie (everything
170
+ # in scope when resolving its own imports) plus each dep's
171
+ # *exported* trie (what the dep ships to consumers). Deps are
172
+ # processed earlier by topological order, so their export
173
+ # tries already exist.
174
+ symbol_lookup = SymbolTrie()
175
+ symbol_lookup.merge(current_trie)
176
+ for dep in paths.get(base, []):
177
+ symbol_lookup.merge(export_tries.get(dep, base_tries[dep]))
178
+
179
+ for src, dst in resolve_edges(import_edges, symbol_lookup, base):
180
+ symbol_graph.add_edge(src, dst)
181
+
182
+ # Plugin pass for this base, with the symbol lookup that was
183
+ # valid at this base's resolution time and the parsed modules
184
+ # we just walked. The context (and ``base_modules``) goes out
185
+ # of scope once we move to the next base, so the parsed CSTs
186
+ # for this base can be GC'd.
187
+ if plugins:
188
+ ctx = PluginContext(
189
+ graph=symbol_graph,
190
+ symbol_lookup=symbol_lookup,
191
+ base=base,
192
+ project_root=root,
193
+ )
194
+ if base_modules is not None:
195
+ for path, module in base_modules.items():
196
+ ctx.prime_module(path, module)
197
+ for plugin in plugins:
198
+ if not isinstance(plugin, EdgePlugin):
199
+ raise TypeError(f"Plugin {plugin!r} does not satisfy EdgePlugin protocol")
200
+ # Materialize before applying so plugins can iterate
201
+ # ctx.graph.nodes without tripping "dictionary changed
202
+ # size during iteration".
203
+ ops = list(plugin.contribute(ctx))
204
+ apply_ops(symbol_graph, ops)
205
+
206
+ return symbol_graph
207
+
208
+
209
+ def _under_any(file: Path, roots: list[Path]) -> bool:
210
+ """True iff ``file`` is equal to or nested under any of ``roots``."""
211
+ f = file.resolve()
212
+ for r in roots:
213
+ if f == r or f.is_relative_to(r):
214
+ return True
215
+ return False
216
+
217
+
218
+ def _infer_project_root(paths: PathMap) -> Path:
219
+ bases = list(paths)
220
+ if not bases:
221
+ return Path.cwd()
222
+ return min(bases, key=lambda p: len(p.parts))
223
+
224
+
225
+ def find_reachable(graph: nx.DiGraph) -> set[SymbolNode]:
226
+ """BFS forward from every node tagged as an entrypoint by a plugin.
227
+
228
+ Plugins mark seeds by setting ``graph.nodes[node]["entrypoint"] = True``
229
+ (see :func:`dead_cst._plugins.apply_ops`). There is no longer any
230
+ built-in matching against file paths or FQNs -- that lives in
231
+ :class:`ExplicitEntrypointPlugin`.
232
+ """
233
+ visited: set[SymbolNode] = set()
234
+ stack = [n for n, attrs in graph.nodes(data=True) if attrs.get("entrypoint")]
235
+ while stack:
236
+ node = stack.pop()
237
+ if node in visited:
238
+ continue
239
+ visited.add(node)
240
+ stack.extend(graph.successors(node))
241
+ return visited
242
+
243
+
244
+ def count_nodes(graph: nx.DiGraph, prefix: Path | None) -> dict[str, int]:
245
+ """Count nodes in ``graph`` by ``SymbolNode.type``, optionally restricted by path.
246
+
247
+ If ``prefix`` is given, only nodes whose ``path`` is under ``prefix`` are
248
+ counted -- useful for per-base summaries when several packages are
249
+ analysed together. Includes the synthetic ``"synthetic"`` type contributed
250
+ by plugins and third-party-dep markers; the CLI suppresses that key when
251
+ rendering summaries.
252
+ """
253
+ counts = {}
254
+ for node in graph.nodes:
255
+ if prefix and not node.path.is_relative_to(prefix):
256
+ continue
257
+ counts[node.type] = counts.get(node.type, 0) + 1
258
+ return counts
dead_cst/_branches.py ADDED
@@ -0,0 +1,203 @@
1
+ """Static evaluator for conditional truthiness.
2
+
3
+ Determines, when possible, whether the truthiness of an expression is
4
+ statically known. Used to identify dead branches of ``if`` / ``while``
5
+ statements so callers can prune references that live inside them.
6
+
7
+ Only handles a small whitelist of literal forms: the ``True`` /
8
+ ``False`` / ``None`` keywords, integer / string literals,
9
+ empty-vs-non-empty collection literals, and ``not`` / ``and`` / ``or``
10
+ over those. Anything involving a name lookup (other than the three
11
+ keywords), attribute access, function call, comparison, or other
12
+ dynamic operation returns ``None`` (unknown). Returning ``None`` is
13
+ always the safe default: callers must treat the branch as live.
14
+
15
+ This module also owns the convention for synthetic graph nodes that
16
+ represent dead suites. The nodes have ``type="synthetic"`` (the
17
+ existing escape hatch in :mod:`dead_cst._symbols`) and a fqname
18
+ prefixed with :data:`UNREACHABLE_PREFIX` (``<unreachable>:``).
19
+ ``is_unreachable_node`` is the single source of truth for that
20
+ convention; consumers should never check the prefix string themselves.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from pathlib import Path
26
+ from typing import Sequence
27
+
28
+ import libcst as cst
29
+ from libcst.metadata import CodeRange
30
+
31
+ from ._symbols import SymbolNode
32
+
33
+
34
+ _KEYWORDS: dict[str, bool] = {
35
+ "True": True,
36
+ "False": False,
37
+ "None": False,
38
+ }
39
+
40
+ UNREACHABLE_PREFIX = "<unreachable>:"
41
+
42
+
43
+ def evaluate_truthiness(node: cst.BaseExpression) -> bool | None:
44
+ """Best-effort static truthiness for ``node``.
45
+
46
+ Returns ``True`` / ``False`` if the value's truthiness is statically
47
+ determinable, ``None`` otherwise. Never raises.
48
+ """
49
+ if isinstance(node, cst.Name):
50
+ return _KEYWORDS.get(node.value)
51
+
52
+ if isinstance(node, cst.Integer):
53
+ try:
54
+ return int(node.value, 0) != 0
55
+ except ValueError:
56
+ return None
57
+
58
+ if isinstance(node, (cst.SimpleString, cst.ConcatenatedString)):
59
+ try:
60
+ value = node.evaluated_value
61
+ except Exception:
62
+ return None
63
+ if value is None:
64
+ return None
65
+ return bool(value)
66
+
67
+ if isinstance(node, (cst.Tuple, cst.List, cst.Set)):
68
+ for elt in node.elements:
69
+ if isinstance(elt, cst.StarredElement):
70
+ return None
71
+ return len(node.elements) > 0
72
+
73
+ if isinstance(node, cst.Dict):
74
+ for elt in node.elements:
75
+ if isinstance(elt, cst.StarredDictElement):
76
+ return None
77
+ return len(node.elements) > 0
78
+
79
+ if isinstance(node, cst.UnaryOperation) and isinstance(node.operator, cst.Not):
80
+ inner = evaluate_truthiness(node.expression)
81
+ return None if inner is None else not inner
82
+
83
+ if isinstance(node, cst.BooleanOperation):
84
+ left = evaluate_truthiness(node.left)
85
+ if isinstance(node.operator, cst.And):
86
+ if left is False:
87
+ return False
88
+ right = evaluate_truthiness(node.right)
89
+ if right is False:
90
+ return False
91
+ if left is True and right is True:
92
+ return True
93
+ return None
94
+ if isinstance(node.operator, cst.Or):
95
+ if left is True:
96
+ return True
97
+ right = evaluate_truthiness(node.right)
98
+ if right is True:
99
+ return True
100
+ if left is False and right is False:
101
+ return False
102
+ return None
103
+
104
+ return None
105
+
106
+
107
+ def unreachable_suites(stmt: cst.BaseStatement) -> list[cst.BaseSuite]:
108
+ """Return every dead suite inside ``stmt``.
109
+
110
+ Supports ``cst.If`` (including ``elif`` / ``else`` chains) and
111
+ ``cst.While``. Returns ``[]`` for any other statement type or when
112
+ no branch can be shown to be unreachable.
113
+
114
+ Returns the suite nodes themselves so callers that need source
115
+ positions (e.g. the visitor) can read them off the node.
116
+ """
117
+ if isinstance(stmt, cst.If):
118
+ return _unreachable_in_if(stmt, branch_taken=False)
119
+ if isinstance(stmt, cst.While):
120
+ truth = evaluate_truthiness(stmt.test)
121
+ if truth is False:
122
+ return [stmt.body]
123
+ # ``while True:`` exits only via break / return / exception, so
124
+ # the ``else`` clause (which fires on normal exit) never runs.
125
+ if truth is True and stmt.orelse is not None:
126
+ return [stmt.orelse.body]
127
+ return []
128
+ return []
129
+
130
+
131
+ def unreachable_bodies(stmt: cst.BaseStatement) -> list[Sequence[cst.CSTNode]]:
132
+ """Return the ``.body`` of every dead suite inside ``stmt``.
133
+
134
+ Thin wrapper over :func:`unreachable_suites` for callers that only
135
+ need the statement list, not the enclosing suite. Each entry is
136
+ typed as ``Sequence[cst.CSTNode]`` because libcst's
137
+ ``BaseSuite.body`` may be ``Sequence[BaseStatement]`` (an indented
138
+ block) or ``Sequence[BaseSmallStatement]`` (a one-line suite like
139
+ ``if False: x = 1``).
140
+ """
141
+ return [suite.body for suite in unreachable_suites(stmt)]
142
+
143
+
144
+ def _unreachable_in_if(node: cst.If, branch_taken: bool) -> list[cst.BaseSuite]:
145
+ """Walk an ``if`` / ``elif`` / ``else`` chain collecting dead suites.
146
+
147
+ ``branch_taken`` is ``True`` when an earlier branch in the chain is
148
+ known to fire; everything from this point on is then unreachable.
149
+ """
150
+ dead: list[cst.BaseSuite] = []
151
+ truth = None if branch_taken else evaluate_truthiness(node.test)
152
+
153
+ if branch_taken or truth is False:
154
+ dead.append(node.body)
155
+
156
+ next_taken = branch_taken or truth is True
157
+
158
+ orelse = node.orelse
159
+ if orelse is None:
160
+ return dead
161
+ if isinstance(orelse, cst.Else):
162
+ if next_taken:
163
+ dead.append(orelse.body)
164
+ return dead
165
+ dead.extend(_unreachable_in_if(orelse, next_taken))
166
+ return dead
167
+
168
+
169
+ def make_unreachable_fqname(module_fqname: str, position: CodeRange) -> str:
170
+ """Build the fqname for the synthetic node representing a dead suite.
171
+
172
+ The format is intentionally fixed: callers should use
173
+ :func:`is_unreachable_node` to identify these nodes rather than
174
+ string-matching on their fqnames.
175
+ """
176
+ return f"{UNREACHABLE_PREFIX}{module_fqname}:{position.start.line}:{position.start.column}"
177
+
178
+
179
+ def make_unreachable_node(module_fqname: str, path: Path, position: CodeRange) -> SymbolNode:
180
+ """Build the synthetic ``SymbolNode`` for a dead suite.
181
+
182
+ Reuses ``type="synthetic"`` -- the existing escape hatch for graph
183
+ nodes that don't correspond to a top-level declaration -- and
184
+ distinguishes itself by carrying a real source ``position`` (rather
185
+ than the ``SYNTHETIC_POSITION`` sentinel used by entrypoint plugins)
186
+ plus the :data:`UNREACHABLE_PREFIX` fqname prefix.
187
+ """
188
+ return SymbolNode(
189
+ fqname=make_unreachable_fqname(module_fqname, position),
190
+ type="synthetic",
191
+ path=path,
192
+ position=position,
193
+ )
194
+
195
+
196
+ def is_unreachable_node(sym: SymbolNode) -> bool:
197
+ """Predicate: ``True`` iff ``sym`` is a synthetic dead-suite node.
198
+
199
+ The only supported way to identify these nodes. Callers should not
200
+ string-match on the fqname directly; the prefix is an implementation
201
+ detail of this module.
202
+ """
203
+ return sym.type == "synthetic" and sym.fqname.startswith(UNREACHABLE_PREFIX)
dead_cst/_codemod.py ADDED
@@ -0,0 +1,135 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import libcst as cst
6
+ import networkx as nx
7
+ from libcst.codemod import CodemodContext
8
+ from libcst.codemod.visitors import RemoveImportsVisitor
9
+ from libcst.metadata import CodeRange, FullRepoManager, PositionProvider, QualifiedNameSource
10
+
11
+ from ._fqn import FixedFullyQualifiedNameProvider
12
+ from ._symbols import SymbolNode
13
+
14
+
15
+ class RemoveDeadSymbols(cst.CSTTransformer):
16
+ METADATA_DEPENDENCIES = (FixedFullyQualifiedNameProvider, PositionProvider)
17
+
18
+ def __init__(self, dead_decls: set[tuple[str, CodeRange]]):
19
+ # ``(fqname, position)`` pairs. The position disambiguates same-name
20
+ # decls so a shadowed dead binding does not drag its live sibling
21
+ # out with it (and vice versa).
22
+ self.dead_decls = dead_decls
23
+
24
+ def _should_remove(self, node: cst.CSTNode) -> bool:
25
+ fqnames = self.get_metadata(FixedFullyQualifiedNameProvider, node, default=[])
26
+ pos = self.get_metadata(PositionProvider, node, default=None)
27
+ return any(
28
+ (qn.name, pos) in self.dead_decls
29
+ for qn in fqnames
30
+ if qn.source == QualifiedNameSource.LOCAL
31
+ )
32
+
33
+ def leave_FunctionDef(self, original_node: cst.FunctionDef, updated_node: cst.FunctionDef):
34
+ if self._should_remove(original_node):
35
+ return cst.RemoveFromParent()
36
+ return updated_node
37
+
38
+ def leave_ClassDef(self, original_node: cst.ClassDef, updated_node: cst.ClassDef):
39
+ if self._should_remove(original_node):
40
+ return cst.RemoveFromParent()
41
+ return updated_node
42
+
43
+ def leave_Assign(self, original_node: cst.Assign, updated_node: cst.Assign):
44
+ new_targets = []
45
+ for orig_target, new_target in zip(original_node.targets, updated_node.targets):
46
+ if not self._should_remove(orig_target.target):
47
+ new_targets.append(new_target)
48
+
49
+ if not new_targets:
50
+ return cst.RemoveFromParent()
51
+
52
+ return updated_node.with_changes(targets=new_targets)
53
+
54
+ def leave_AnnAssign(self, original_node: cst.AnnAssign, updated_node: cst.AnnAssign):
55
+ if self._should_remove(original_node.target):
56
+ return cst.RemoveFromParent()
57
+ return updated_node
58
+
59
+
60
+ def _import_remove_args(node: SymbolNode) -> tuple[str, str | None, str | None]:
61
+ """Convert an ``"import"``-typed node to ``RemoveImportsVisitor`` args.
62
+
63
+ Returns ``(module, obj, asname)`` matching the signature of
64
+ :meth:`RemoveImportsVisitor.remove_unused_import`. ``asname`` is set
65
+ only when the local bound name differs from the natural binding
66
+ (``obj`` for ``from X import obj``, the leftmost segment of
67
+ ``module`` for bare ``import X``).
68
+ """
69
+ assert node.imports is not None, f"Import node missing imports metadata: {node.fqname}"
70
+ module = str(node.imports.module)
71
+ obj = node.imports.decl
72
+ bound = node.fqname.rsplit(".", 1)[-1]
73
+ natural = obj if obj is not None else module.split(".", 1)[0]
74
+ asname = bound if bound != natural else None
75
+ return module, obj, asname
76
+
77
+
78
+ def remove_code(G: nx.Graph, base: Path) -> None:
79
+ """Delete every symbol in ``G`` from the source files under ``base``.
80
+
81
+ Modules are removed by unlinking the file. Functions, classes, and
82
+ top-level variables are dropped by a LibCST transformer matching on
83
+ ``(fqname, position)`` so a shadowed dead binding does not drag its
84
+ live sibling out with it. Surviving statements, comments, and
85
+ formatting around the deletions are preserved.
86
+
87
+ Imports are handled in a second pass via libcst's
88
+ :class:`RemoveImportsVisitor`, which walks scopes itself and skips
89
+ anything still referenced (defensive -- the graph already classifies
90
+ these as dead, but the scope check is cheap insurance).
91
+
92
+ ``G`` is typically the unreachable subgraph of the graph from
93
+ :func:`build_symbol_graph`; only the nodes are inspected, edges are
94
+ ignored. Symbols whose path is not under ``base`` are skipped, so call
95
+ once per base when analysing several packages together. The
96
+ transformation is destructive -- back the files up first, or run on a
97
+ clean working tree.
98
+ """
99
+ by_file: dict[Path, list[SymbolNode]] = {}
100
+ for node in G.nodes:
101
+ if not node.path.is_relative_to(base):
102
+ continue
103
+ if not node.path.exists():
104
+ continue
105
+ match node.type:
106
+ case "function" | "class" | "variable" | "import":
107
+ by_file.setdefault(node.path, []).append(node)
108
+ case "module":
109
+ node.path.unlink()
110
+
111
+ mgr = FullRepoManager(str(base), [str(p) for p in by_file], {FixedFullyQualifiedNameProvider})
112
+ for path, nodes in sorted(by_file.items(), key=lambda x: x):
113
+ if not path.exists():
114
+ continue
115
+
116
+ # Pass 1: drop dead defs / classes / variables. Imports they
117
+ # used to reference become eligible for removal in pass 2.
118
+ wrapper = mgr.get_metadata_wrapper_for_path(str(path))
119
+ dead_decls = {(n.fqname, n.position) for n in nodes if n.type != "import"}
120
+ result = wrapper.visit(RemoveDeadSymbols(dead_decls))
121
+
122
+ # Pass 2: hand the dead-import set to libcst's stock import
123
+ # remover. It walks scopes itself, so it'll skip anything still
124
+ # referenced after pass 1 (defensive -- if the graph said
125
+ # something is dead, no live user remains).
126
+ dead_imports = [n for n in nodes if n.type == "import"]
127
+ if dead_imports:
128
+ ctx = CodemodContext()
129
+ for imp in dead_imports:
130
+ module, obj, asname = _import_remove_args(imp)
131
+ RemoveImportsVisitor.remove_unused_import(ctx, module, obj, asname)
132
+ result = RemoveImportsVisitor(ctx).transform_module(result)
133
+
134
+ with path.open("w") as f:
135
+ f.write(result.code)