This doc walks the code path a single dead-cst invocation takes, from
CLI arguments to a written patch. It's the developer-facing companion
to README.md (user-facing) and CLAUDE.md
(LLM-oriented summary). Read this before adding a plugin, resolver, or
touching the rust crate.
CLI / Python API
│
▼
┌──────────────────┐
│ PathResolver │ resolve(project_root) -> tuple[Package, ...]
└─────────┬────────┘
│ Packages (path, name, deps)
▼
┌──────────────────┐
│ Analysis │ cheap construction; everything below is lazy
└─────────┬────────┘
│ materialize_all()
▼
┌──────────────────────────────────────────────────────────────┐
│ rust crate: native.ProjectContext.materialize() │
│ │
│ Phase 1 — decls ty SemanticIndex + ruff AST │
│ Phase 2 — chain parent-module + import edges │
│ Phase 3 — references ty use-def chains for every Name │
│ Plugins NativePlugin ops → Node/Edge/Entry │
└────────────────────────────┬─────────────────────────────────┘
│ live ProjectContext
▼
┌──────────────┴──────────────┐
▼ ▼
Analysis queries codemod (libcst)
(reachable / dead / remove_code,
descendants / ancestors generate_patch
— all run in rust)
│
▼
graph persistence
write_graph / read_graph
(bincode)
The entire graph build runs inside the rust extension. There is no
per-file Python cache; ty's Salsa database already provides incremental
re-analysis. The only stage still in Python is the LibCST codemod, and
the only stage still in pure Python is Analysis's thin wrapper around
the rust BFS queries.
The native code is split in two:
dead-cst-runtime(runtime/) — the whole implementation: the build pipeline, the query surface, the pyclasses, the native-plugin API. Built as both anrliband adylib.dead-cst-native(src/lib.rs) — a thin pyo3#[pymodule]shim that ships aspython/dead_cst/_native.{abi3.so,pyd}.
The default wheel links the runtime rlib statically into the shim, so it's
a single self-contained extension (~10 MB) that needs no rust at install time.
The runtime dylib is what external native plugins link against to share
one salsa/ty instance — see NATIVE_PLUGINS.md. Type
stubs live at python/dead_cst/_native.pyi.
Module layout (runtime/src/lib.rs's register() registers the pymodule; the
src/lib.rs shim forwards to it):
| File | Purpose |
|---|---|
src/lib.rs |
the cdylib shim: pyo3 #[pymodule] forwarding to runtime::register |
runtime/src/lib.rs |
register() — the pyo3 module registration |
runtime/src/project.rs |
Project, ProjectContext, the build() pipeline |
runtime/src/builder.rs |
NodeKey, GraphBuilder, PreparedOp (Node / Edge / Entrypoint), generic BFS |
runtime/src/graph.rs |
SymbolNode, Import, NativeGraph, NodeFlags, EdgeFlags |
runtime/src/ingest.rs |
the three build phases (decls / chain / references) |
runtime/src/query.rs |
shared per-file scan helpers (identifier prefilter, path-regex, par_scan_files) |
runtime/src/native_plugins.rs |
in-tree + external native plugins (the plugin_api, the ABI airlock) |
runtime/src/helpers.rs |
shared utilities (noqa parser, notebook decoder, dist-info lookup, …) |
runtime/src/io.rs |
write_graph / read_graph (bincode + a hard-versioned header) |
The deeper crate-level rules live in runtime/src/CLAUDE.md — ty is the source
of truth for every piece of Python semantics, every import binds a
local declaration, and shadowed declarations are first-class graph
nodes.
A PathResolver answers one question: what packages exist in this
project, and which other packages does each depend on?
resolve(project_root) -> tuple[Package, ...]Each Package carries path, name, and deps: tuple[str, ...]
(referencing other packages by name). Builtins:
ManualResolver(dead_cst/resolvers/manual.py) — explicitpackage:dep1,dep2specs from the CLI's-pflag.UvResolver(dead_cst/contrib/uv.py) — parsesuv.lockto discover workspace members and inter-member edges.
Analysis takes exactly one resolver — no chain. CLI flags -p and
--resolver are mutually exclusive. Construction validates the
resolver's output (name uniqueness, dep references resolve) via
_validate_packages.
Cheap to construct: the resolver runs once at __init__ and packages
are sorted into a dep-first BFS order, but nothing else happens until
you ask a question (materialize_all, reachable, dead,
descendants, ancestors, kept_alive_by_flags_only,
kept_alive_by_dead_branches).
Analysis._ctx holds the live native.ProjectContext after the first
materialize_all() call, so subsequent BFS queries run on the already-
built graph — one FFI hop per query, no rebuild.
Analysis.re_materialize(events) rebuilds the graph against the
same ProjectContext without tearing down the salsa db. The caller
supplies the change set: typically ctx.detect_changes() (which
today returns a single ChangeEvent.rescan()), or an explicit
list[native.ChangeEvent] built via the .changed(path) /
.created(path) / .deleted(path) / .rescan() classmethods for
LSP / file-watcher integrations. ctx.apply_changes(events) forwards
into ty's ProjectDatabase::apply_changes, which bumps file
revisions only when mtime / size differ, registers Created paths so
new files are visible on the next walk, drops Deleted paths, and
triggers a full rescan + project reload for the Rescan sentinel.
Cross-file importers invalidate transitively through salsa's
auto-tracked file_to_nodes reads. Plugin prepare(project_root) is
one-shot (owned by materialize_all); re_materialize only
re-drives the build + plugin pass.
Driven from Python by Analysis.materialize_all():
ctx = native.ProjectContext(project_root, src_roots=[pkg.path, ...])
for plugin in self._plugins:
ctx.add_plugin(plugin)
ctx.materialize()Each package's path is registered as a src_root so the rust backend
mounts files at the right module FQN (pkg_a/A/__init__.py → A, not
pkg_a.A). Inside materialize() the rust pipeline runs in three
phases (see src/lib.rs for the canonical comment):
Phase 1 — decls. For every project file, iterate every binding in
the file's global scope via UseDefMap::all_definitions_with_usage,
minting a SymbolNode per binding (including each name brought in by
from foo import *). Each node lands in a global
(File, target_range) → node_idx index so cross-file edges can find
it later. Shadowed declarations are first-class — two def f at
different lines stay as distinct nodes, distinguished by their
positional NodeKey.
Phase 2 — chain. For every module node, emit the parent-module
edge so __init__.py stays alive as long as anything in the package
does. For every import-kind binding, resolve the upstream target via
ty_module_resolver::resolve_module and emit alias_node → upstream_node. Targets outside the project (stdlib, site-packages)
get lazily minted module-only nodes under the
[stdlib] X / [external dist] X / [external file] X /
[unresolved] X synthetic prefixes.
Phase 3 — references. For every Definition that owns an
expression (function body, class body, assignment RHS, annotation),
walk the contained Names and resolve each to its reaching def via
ty's visible_ancestor_scopes + end_of_scope_symbol_bindings. The
local alias is the target, not the upstream definition. Module-level
non-definition statements attribute to the module node itself.
Statically-dead regions identified by ty's reachability constraints
get their outgoing edges flagged EdgeFlags.DEAD_BRANCH.
After the three phases, the registered plugins contribute graph ops.
Every plugin is a native (Rust) NativePlugin; there is no Python
Plugin protocol. Project-wide plugins fan out across a GIL-free
rayon scope, each running against a FrozenView (a Send snapshot
of the build outputs) and collecting PreparedOp rows (Node / Edge
/ Entrypoint); per-file plugins are salsa-cached during the build. The
rust apply pass folds every plugin's ops into the graph atomically
(apply_prepared_batch), in registration order, before materialize()
returns. There is no two-phase observe/finalize split — one collect, one
apply.
A plugin builds against the query surface on native.ProjectContext:
the *_indices queries (find_declarations_indices,
module_surface_indices, decls_matching_indices, indices_where, …)
return positional indices into ctx.nodes(), materialized in bulk via
ctx.nodes_at(idxs) / ctx.node_attrs(idxs). Direct accessors
(find_module_idx, module_for_indices, find_main_blocks_indices,
find_factory_decls, …) cover the rest. The decorator / construction /
call walks the dispatch-app plugins drive are rust-internal. See
python/dead_cst/_native.pyi for the full surface.
Every built-in is resolved through native._builtin_native_plugin
(consulted first by the CLI's _load_plugin, which otherwise falls
through to the dead_cst.plugins entry-point group). The dispatch-app
frameworks (Flask / FastAPI / Typer / Cyclopts / Slack Bolt / FastMCP /
Celery), Click, pytest, mock_patch, discordpy, and the rest are all
*PluginImpls in runtime/src/native_plugins.rs (see ClickPluginImpl,
DispatchAppPluginImpl, and friends). Out-of-tree plugins are external
native plugins compiled against the runtime dylib (see
NATIVE_PLUGINS.md).
BFS from every node whose flags overlap seed_flags. Default seed
mask is KEEPALIVE_DEFAULT = ENTRYPOINT | TESTCASE | NOQA | NOTEBOOK.
Default traversal does follow DEAD_BRANCH edges (preserving
historical behavior). Three queries cover the standard slicing:
Analysis.dead(seed_flags=...)— every decl not reached from any seed in the mask.Analysis.kept_alive_by_dead_branches()— diff of the default closure against the strict closure that skips dead-branch edges.Analysis.kept_alive_by_flags_only(flags, ...)— diff ofreachable(seed_flags)againstreachable(seed_flags & ~flags). PassNodeFlags.TESTCASEfor the "what dies if the test suite goes away" question,NodeFlags.NOQAfor the stale-F401 audit, or OR them together.
All four delegate to a rust BFS in one FFI call — there is no Python adjacency walk on the hot path.
dead-cst build ROOT -o PATH materializes the graph once and writes it
to disk as a bincode blob prefixed with the magic bytes DEADCSTG and
a u32 format version. dead-cst analyze --graph PATH /
dead-cst remove --graph PATH skip the build entirely and load the
file instead.
The library-level twin:
from dead_cst.graph import write_graph, read_graph
graph = Analysis(root, resolver=...).materialize_all()
write_graph("graph.bin", graph, meta=[("branch", "main")])
loaded, metadata = read_graph("graph.bin")
loaded.reachable(seed_flags=NodeFlags.ENTRYPOINT) # pure-Python BFSLoaders are strict — a version mismatch is a fatal error with no
migration path (rebuilding is cheap). The metadata block records
created_at, node / edge / file / line counts, and any
user-supplied (key, value) pairs the CLI's --meta flag threads
through.
Plugins deliberately do not round-trip. Loading a graph gives you
a LoadedGraph (not a ProjectContext), and only the materialized
adjacency is captured — a project that needs plugin-emitted edges has
to rebuild rather than load.
remove_code(dead_nodes, package_path) runs a LibCST
RemoveDeadSymbols transformer keyed on (fqname, start_line) pairs
(line disambiguates shadowed decls), then prunes now-unused imports
via libcst's RemoveImportsVisitor. Position keying is critical —
losing it conflates a dead decl with a live shadow.
generate_patch(dead_nodes, root) is the non-destructive twin: same
selection logic, same two-pass LibCST pipeline, but emits a
git apply-compatible unified diff with diff --git headers (and
deleted file mode 100644 for module-node deletions) instead of
writing back. Selection is driven entirely by the input iterable, so
callers can slice the unreachable set however they like (e.g. one SCC
at a time) to review a big codebase as a series of focused patches.
dead-cst remove uses generate_patch exclusively — it emits the
patch to stdout (or --output PATH) and never mutates source.
The codemod is the only stage that still uses LibCST.
- One node per top-level declaration plus one synthetic module node per file. Nested defs (inner functions, methods, nested classes) are deliberately not given their own nodes — refs from inside them attribute to the enclosing top-level decl.
- A module-level
import/from ... import ...is itself a node of type"import". Local uses of an imported name always edge to the local alias (the codemod invariant: an unused import has zero in-edges). When ty's module resolver / global-scope lookup can pin the use to specific upstream targets, the use also emits direct edges to each of them. - Submodules edge to their parent package so
__init__.pystays alive as long as anything in the package does. EdgeFlags.DEAD_BRANCHis metadata only; default reachability still follows the edge.NodeFlags.SHADOWEDdecls (adef frebound later in the same file) are kept in the graph but excluded from the cross-module lookup so consumer imports route to the live binding.NodeFlags.OVERLOADfollows the same trie-exclusion rule asSHADOWED; lifetime is anchored to the matching same-file impl via explicitimpl -> overloadedges.NodeFlags.NOQAflags import aliases preserved by a user ruff/pyflakes noqa directive (per-line# noqa,# noqa: F401,# noqa: E501, F401, or file-level# ruff: noqa/# flake8: noqa). Layered onENTRYPOINT.NodeFlags.NOTEBOOKtags every node sourced from a Jupyter.ipynbfile. Notebook cells run top-to-bottom rather than being imported, so the bit alone keeps the node alive (noENTRYPOINToverlay needed —NOTEBOOKis inKEEPALIVE_DEFAULT). The codemod skips notebook nodes (cell-aware writeback into the JSON envelope is out of scope today).NodeFlags.STAR_REEXPORTtags synthetickind="import"nodes minted for each name brought in byfrom X import *. They live in the cross-module lookup like real re-export imports; the codemod skips them (there is no per-name source line to remove)..pyistubs are ingested only for the compiled-extension layout (_native.so+_native.pyi, no.pytwin). Peer-mode stubs alongside a real.pyare dropped — the runtime always wins.
| If you want to… | Touch |
|---|---|
| Recognize a new decl shape | src/ingest.rs (Phase 1) |
| Tweak how imports resolve | src/ingest.rs (Phase 2, emit_import_edges) |
| Tweak how references attribute | src/ingest.rs (Phase 3, emit_reference_edges) |
| Add a new plugin query | a *_indices pymethod on ProjectContext (src/project.rs) |
| Keep alive symbols a framework registers dynamically | new NativePlugin impl in src/native_plugins.rs |
| Support a new project layout / lockfile | new PathResolver under dead_cst/contrib/ |
| Change graph persistence format | src/io.rs (bump the format version) |
| Change codemod output shape | dead_cst/codemod.py (RemoveDeadSymbols / _rewrite_one) |
| Change patch format / per-SCC patch slicing | dead_cst/codemod.py (generate_patch) |
See CLAUDE.md for the top-level invariants and src/CLAUDE.md for
the rust crate's principles.