How to Detect Dead Code by Walking Call and Reference Edges in code-graph-rag
The dead-code detector in code-graph-rag performs a multi-source BFS from entry points across CALLS and REFERENCES edges, then expands factory classes and override chains until fixed point.
Dead code increases maintenance burden and binary bloat. The code-graph-rag repository provides a language-agnostic dead-code detector that identifies unreachable functions, methods, and classes by walking call-graph edges from known entry points. This guide explains exactly how the detection works and how to use it in your own projects.
How the Dead-Code Detector Works
The implementation in codebase_rag/dead_code.py uses a three-stage pipeline optimized for large codebases.
Stage 1: Identify Reachability Roots
Before walking the graph, the detector must find all entry points where execution can begin. Root detection (lines 12-36 of dead_code.py) combines three strategies:
-
Root decorators — patterns like
@route,@task,@fixture,@app.getdefined inDEFAULT_ROOT_DECORATORS -
Language-specific runtime hooks —
main/initfunctions, Rust trait methods, Java serialization hooks, C# attributes, NestJS decorators, React lifecycle methods -
Explicit user-supplied roots — via
config.entry_points
Helper predicates in dead_code.py implement this logic:
| Helper Function | Purpose |
|---|---|
_is_dunder |
Python magic methods (implicitly reachable) |
_is_rust_runtime_root |
main, panic_handler, alloc_error_handler |
_is_cpp_operator_root |
Operator overloads that the runtime invokes |
_is_nest_root |
NestJS controller, service, module decorators |
_is_react_root |
Component lifecycle and hook methods |
Root constants live in codebase_rag/constants/deadcode_roots.py, making the framework extensible for new languages.
Stage 2: Fetch the Complete Graph
Two Cypher queries in codebase_rag/cypher_queries.py pull the full call-graph:
# CYPHER_DEAD_CODE_NODES — all symbols with project prefix
# Returns: qualified_name, path, start_line, end_line, kind
# CYPHER_DEAD_CODE_RELS — all reachability edges
# Returns: source_qualified_name, source_kind, rel_type, target_qualified_name, target_kind
The fetched relationships include:
CALLS— direct function/method invocationsREFERENCES— variable reads, attribute accessINSTANTIATES— class instantiationINHERITS,DEFINES,DEFINES_METHOD— class hierarchyOVERRIDES,IMPLEMENTS— polymorphic dispatch
Stage 3: Walk the Graph with BFS and Fixed-Point Expansion
The _walk function (lines 75-90 of dead_code.py) implements a multi-source BFS:
def _walk(frontier, adjacency, live):
"""Expand reachability across CALLS and REFERENCES edges."""
queue = deque(frontier)
while queue:
node = queue.popleft()
for neighbor in adjacency.get(node, []):
if neighbor not in live:
live.add(neighbor)
queue.append(neighbor)
After the initial walk, a fixed-point expansion (lines 60-86) handles dynamic dispatch patterns:
- Decorated closures — functions returned by decorators
- Factory classes — methods on classes that instantiate other live classes
- Override chains — methods that override already-live base methods
This repeats until no new nodes become live. Nodes never marked live are reported as dead code.
The algorithm avoids the original O(roots × graph) complexity by using two linear scans: one to build adjacency, one to propagate reachability.
Running Dead-Code Detection
The public API exposes collect_dead_code with configurable behavior:
from codebase_rag.dead_code import collect_dead_code, default_dead_code_config
from codebase_rag.graph_loader import GraphQueryClient
# Initialize your Neo4j-backed graph client
client = GraphQueryClient(uri="bolt://localhost:7687", auth=("neo4j", "password"))
# Configure detection behavior
config = default_dead_code_config(
include_tests=False, # Exclude test files from analysis
include_classes=True, # Treat class definitions as dead-code candidates
exclude_patterns=("*/generated/*", "*/migrations/*") # Skip generated code
)
# Add explicit entry points not discoverable by heuristics
config.entry_points = ("myproject.cli.main", "myproject.worker.start_worker")
# Execute detection
dead_rows = collect_dead_code(client, project_prefix="myproject", config=config)
# Report results
for row in dead_rows:
print(f"{row['qualified_name']}: {row['path']}:{row['start_line']}-{row['end_line']}")
Each returned row contains:
qualified_name— fully qualified symbol namepath— source file pathstart_line/end_line— location in sourcekind— function, method, class, or module
Understanding the Internal Graph Walk
For advanced customization, the core dead_code_from_graph function shows how reachability propagates:
from collections import defaultdict
from codebase_rag.dead_code import _walk
def dead_code_from_graph(nodes, relationships, project_prefix, config):
# ① Build adjacency list from CALLS and REFERENCES only
adjacency = defaultdict(set)
for src, src_kind, rel, dst, dst_kind in relationships:
if rel in {"CALLS", "REFERENCES"}:
adjacency[src].add(dst)
# ② Initialize roots from decorators, language hooks, and config
roots = _collect_roots(nodes, config) # implements heuristic detection
# ③ First-pass BFS from all roots
live = set(roots)
_walk(roots, adjacency, live)
# ④ Fixed-point: expand factories and overrides
prev_size = -1
while len(live) != prev_size:
prev_size = len(live)
_expand_factories(nodes, live, adjacency) # lines 60-72
_expand_overrides(nodes, live) # lines 73-86
# ⑤ Return dead symbols
all_nodes = {n['qualified_name'] for n in nodes}
return all_nodes - live
Customizing Root Detection
To add framework-specific entry points, modify codebase_rag/constants/deadcode_roots.py or pass additional patterns via config:
from codebase_rag.constants.deadcode_roots import DEFAULT_ROOT_DECORATORS
# Extend for a custom Django-like framework
MY_DECORATORS = DEFAULT_ROOT_DECORATORS | {
r"@command", # CLI commands
r"@signal", # Signal handlers
r"@admin\.register", # Django admin hooks
}
config = default_dead_code_config()
config.root_decorator_patterns = MY_DECORATORS
Language-specific helpers in dead_code.py follow a consistent pattern: test the node's qualified name, decorators, or parent scope against known framework conventions.
Key Files and Their Roles
| File | Responsibility |
|---|---|
codebase_rag/dead_code.py |
Core engine: root detection (_is_* helpers), BFS walk (_walk), fixed-point expansion, public API (collect_dead_code) |
codebase_rag/constants/deadcode_roots.py |
Declarative registry of entry-point patterns by language/framework |
codebase_rag/cypher_queries.py |
Cypher query definitions: CYPHER_DEAD_CODE_NODES, CYPHER_DEAD_CODE_RELS |
codebase_rag/graph_loader.py |
GraphQueryClient for executing Cypher against Neo4j |
evals/dead_code.py |
Reference implementation showing end-to-end usage |
Summary
- Dead-code detection identifies unreachable symbols by walking CALLS and REFERENCES edges from known entry points
- Root detection combines decorator patterns, language runtime hooks, and user configuration in
dead_code.pylines 12-36 - Graph walk uses multi-source BFS in
_walk(lines 75-90) followed by fixed-point expansion for factories and overrides - Two Cypher queries fetch all nodes and edges; the algorithm then operates in-memory for performance
- The public API via
collect_dead_codesupports language-agnostic detection with customizable filters and explicit entry points
Frequently Asked Questions
How does the detector handle dynamic dispatch and virtual methods?
The detector uses a fixed-point expansion phase after the initial BFS. When a base method is marked live, _expand_overrides revives all overriding implementations. Similarly, _expand_factories activates methods on classes that instantiate other live classes. This process repeats until no new nodes are discovered, approximating runtime dispatch without executing code.
Can I use this for languages other than Python?
Yes. The code-graph-rag architecture is language-agnostic. Language-specific logic lives in helper predicates like _is_rust_runtime_root and _is_cpp_operator_root. To add a new language, extend deadcode_roots.py with entry-point patterns and add a corresponding _is_*_root predicate in dead_code.py.
Why use two linear scans instead of BFS from each root separately?
The original implementation performed per-root BFS with O(roots × graph) complexity, causing timeouts on large codebases. The current design fetches all nodes and edges once, builds an adjacency map, then walks from all roots simultaneously. This reduces complexity to O(nodes + edges) regardless of root count.
What edge types are considered for reachability?
The CALLS and REFERENCES relationships directly propagate reachability during BFS. INSTANTIATES, INHERITS, OVERRIDES, and IMPLEMENTS influence reachability indirectly through the fixed-point expansion phases for factories and polymorphic dispatch.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →