Thanks for your interest. dead-cst is small and pre-release — bug
reports with minimal repros, real-world test cases, and PRs are all
welcome. Expect APIs and CLI flags to keep moving until the first
stable release. See ROADMAP.md for the planned
trajectory.
dead-cst uses uv and
maturin — the rust extension under
src/ builds into python/dead_cst/_native.{abi3.so,pyd} on
uv sync. You need a Rust toolchain (rustup); everything else (uv,
pytest, ruff, prek, ty, maturin) is installed by uv sync.
git clone https://github.com/lpetre/dead-cst
cd dead-cst
uv syncuv run pytest # full suite (e2e is deselected by default)
uv run pytest tests/test_imports.py # one file
uv run pytest -k name_substring # one test by name
uv run pytest -m e2e # opt-in e2e suite (clones real GitHub repos)
uv run pytest --cov=dead_cst --cov-branch --cov-report=term-missing
uv run ptw # pytest-watcher for a tight inner looppyproject.toml pins addopts = "-m 'not e2e'", so the standard
uv run pytest is hermetic. CI runs the matrix pytest on Python
3.11–3.14 plus prek run --all-files on every push and pull request.
Ruff (lint + format), ty (type check), and a small set of hooks run
on every commit via prek, a
Rust-based drop-in replacement for pre-commit:
uv run prek install # one-time, sets up the git hook
uv run prek run --all-filesty type-checks dead_cst/ only — tests, examples, and workspace
fixtures intentionally exercise untyped third-party internals.
src/lib.rs # thin pyo3 cdylib shim -> dead_cst._native (builds via maturin)
runtime/ # dead-cst-runtime crate: the whole impl, built as rlib + dylib
src/lib.rs # register() (the pymodule body)
src/project.rs # Project / ProjectContext / build() pipeline
src/builder.rs # GraphBuilder, PreparedOp (Node / Edge / Entrypoint), BFS
src/graph.rs # SymbolNode / Import / NativeGraph / NodeFlags / EdgeFlags
src/ingest.rs # the three build phases (decls / chain / references)
src/query.rs # shared per-file scan helpers (prefilter, path-regex, par_scan_files)
src/native_plugins.rs # in-tree + external native plugins (plugin_api, ABI airlock)
src/helpers.rs # noqa parser, notebook decoder, dist-info lookup, …
src/io.rs # write_graph / read_graph (bincode + versioned header)
examples/main_block_plugin/ # worked example external native plugin
plugin-host/ # dead-cst-plugin-host package: the `[build-plugin]` extra payload
python/dead_cst/ # Python source tree (ships alongside _native.so in the wheel)
__init__.py # public API: Analysis, SymbolNode, Import, NodeFlags, EdgeFlags
analyze.py # Analysis (wraps the rust ProjectContext for BFS queries)
cli.py # build / analyze / remove + plugin/resolver CLI wiring
codemod.py # remove_code, generate_patch (only stage still on libcst)
graph.py # graph data types + write_graph / read_graph / LoadedGraph
_native.pyi # hand-written type stubs for the rust extension
plugins/ # public synthetic-node prefix constants + simple_name (_core.py)
contrib/ # third-party-aware extensions (public namespace)
resolvers/ # PathResolver, ManualResolver
tests/ # fixture-driven pytest suite (e2e under tests/e2e/)
Modules whose name starts with _ are internal. The supported surface
is the names re-exported from dead_cst/__init__.py plus the focused
public submodules listed in python/dead_cst/__init__.py's docstring.
tests/test_public_api.py pins each module's __all__ against a
snapshot.
Every built-in plugin is a native (Rust) NativePlugin; there is no
Python Plugin protocol. Built-ins are constructed via factories —
NativePlugin.main_block(), NativePlugin.pytest(),
NativePlugin.flask() … NativePlugin.celery(), NativePlugin.click(),
NativePlugin.mock_patch(), NativePlugin.discordpy(), and the rest.
To add a built-in, write the impl in the runtime crate
(runtime/src/native_plugins.rs — see ClickPluginImpl,
DispatchAppPluginImpl, and friends) and wire its --plugin name into
_builtin_native_plugin. The CLI's _load_plugin resolves a name
through that native registry first, then falls back to the
dead_cst.plugins entry-point group.
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 with
ctx.nodes_at(idxs) / ctx.node_attrs(idxs), plus direct accessors
(find_module_idx, module_for_indices, find_main_blocks_indices,
find_factory_decls, …). The decorator / construction / call walks the
dispatch-app plugins drive are rust-internal. See
python/dead_cst/_native.pyi for the full surface.
Out-of-tree plugins register under the dead_cst.plugins entry-point
group; the target must resolve to (or return) a NativePlugin:
[project.entry-points."dead_cst.plugins"]
my_plugin = "myproj.plugins:make_plugin" # -> NativePluginAn out-of-tree NativePlugin is a Rust plugin compiled against the
runtime dylib and loaded via native.load_native_plugins(...); it can
define its own salsa-cached queries over ty's database. The author flow
is pip install dead-cst[build-plugin] then dead-cst build-plugin <PLUGIN.rs> — a preview (macOS + Linux, pinned Rust toolchain,
recompile per release). See NATIVE_PLUGINS.md
and the worked examples/main_block_plugin/.
Implement PathResolver: a resolve(project_root) -> tuple[Package, ...]
method. Drop generic resolvers in dead_cst/resolvers/<name>.py
(re-exported from dead_cst/resolvers/__init__.py); resolvers that
target a specific external tool (like UvResolver for uv.lock)
belong in dead_cst/contrib/<name>.py (re-exported from
dead_cst/contrib/__init__.py). Register the CLI key in
_BUILTIN_RESOLVERS in dead_cst/cli.py.
Tests are fixture-driven from inline source snippets — no checked-in
.py fixture files under tests/. The build_decl_graph fixture
(in tests/conftest.py) writes a {filename: source} dict to a
tmpdir, runs Analysis(...).materialize_all() on it, and returns the
resulting ProjectContext. The assert_edges family compares edges
as "src.fqname -> dst.fqname" strings.
def test_something(build_decl_graph, assert_edges):
graph = build_decl_graph({"mod.py": "def a(): pass\na()"})
assert_edges(graph, {"mod.a -> mod", "mod -> mod.a"})Plugin tests live in tests/test_plugins/ with their own
conftest.py; resolver tests in tests/test_resolvers/. Codemod
tests (tests/test_codemod.py) write a snippet, call remove_code,
and assert on the rewritten text. E2E tests (tests/e2e/) shallow-
clone real repos at pinned SHAs behind the e2e marker (deselected
by default).
A good bug report contains a minimal .py file (or pair of files)
and the entrypoint flag you ran, plus the actual vs. expected dead-
symbol output. The smaller the repro, the faster it gets fixed.
- Keep PRs focused — one logical change per PR.
- Add or update tests for behaviour changes.
- Run
prek run --all-filesandpytestbefore pushing. - If your change is user-visible, add an entry to
CHANGELOG.mdunder[Unreleased].
The version is read from Cargo.toml's [package].version by maturin
(pyproject.toml is dynamic = ["version"]). Bump that field by
hand before tagging a release. On every push to main the publish
workflow rewrites the version to <base>-dev${{ github.run_number }}
in-CI (never committed) so TestPyPI gets a unique version per commit;
the SemVer pre-release suffix is normalized by maturin to PEP 440
<base>.dev<N> for the wheel.
Publishing a GitHub Release with a vX.Y.Z tag triggers
.github/workflows/publish.yml, which builds wheels + sdist with
uv build / maturin, publishes to PyPI via OIDC, and attaches the
artifacts to the release.