A call graph maps function-to-function relationships, showing connections that a text search cannot establish. We use this analysis as the foundation of what YAGNI does, and learning its basics clarifies both its limits and our engineering tradeoffs.
What a Call Graph Is
A call graph is a directed graph whose nodes are functions (or methods) and whose edges show possible calls between them. When function A calls function B, the graph contains an edge from A to B.
A program's full call graph contains every function that could be called and every calling relationship that exists. That is easy to picture in a small program. In a production codebase with thousands of functions, it becomes a data structure that must be built computationally.
For dead code analysis, the call graph's value is direct: a function is reachable if and only if a path exists from an entry point, meaning a function the program invokes without another function in the codebase calling it first, to that function. Without such a path, the function is unreachable, the technical definition of dead from a call-chain perspective.
Static vs. Dynamic Call Graphs
Call graphs are built in two ways: statically, by analyzing source code without running it, or dynamically, by instrumenting a running program and recording what actually calls what.
Static analysis creates a superset, capturing every call that could occur under any execution. A function is marked reachable if any code path reaches it, even when that path is never taken in practice. Thus static call graphs have false positives for live functions: they report reachability where nothing actually reaches the function.
Dynamic analysis creates a subset, recording only calls made during instrumented runs. It marks a function unreachable when the observation period did not call it, but that does not prove it could not be called. Functions on rarely-exercised paths can look dead in dynamic traces while remaining very much live. Dynamic analysis therefore has false negatives for live functions.
Neither method is sufficient by itself. Combining them to produce high-confidence findings when both signals agree is substantially more reliable than using either one alone.
How Static Call Graphs Are Built
Static call graph construction begins by parsing source code into an AST (abstract syntax tree), then traversing it to find call expressions. With a direct function call, the process is straightforward: the parser identifies the callee by name and adds an edge.
Indirect calls start the complexity. Consider these patterns:
# Method call on a parameter with an interface type
def process(handler: EventHandler):
handler.handle(event) # which concrete type is handler?
# Function stored in a variable
callback = get_callback_for_event(event_type)
callback(data) # which function does callback point to?
# Reflection or getattr
method_name = "process_" + event_type
getattr(service, method_name)(data) # no static resolution possible
For interface method calls, the conservative choice adds edges to every concrete implementation. This over-approximates the graph but ensures no live function is missed.
For function variables, points-to analysis can sometimes track which concrete functions are assigned to a variable in a given context. When it succeeds, edges go only to the actually assigned functions. If resolution fails, edges go to all callable assignments in scope.
For reflection-based dispatch, as in the third example, static analysis usually cannot resolve the target. The call becomes an "unknown target," and every function that could match the naming pattern gets a conservative edge.
Where Static Analysis Hits Its Ceiling
Static call graph analysis reliably fails to provide a complete picture in four categories:
Reflection and dynamic dispatch on strings. Python's getattr(), JavaScript's bracket notation with dynamic keys, Java's Class.forName() and Method.invoke(): static analysis cannot trace these string-based calls. Target function names are computed at runtime, not readable from source.
Serialization-based invocation. Message queue consumers, event bus handlers, and RPC frameworks invoke handlers through a string key recorded at registration time. The framework's call site is opaque. Static analysis sees only the registration code, which must be recognized and traced correctly.
Plugin and extension architectures. Frameworks discover and load implementations at runtime from file system paths or configuration. The functions to call remain unknown until the framework runs, so static analysis cannot follow dynamic load paths.
Code generation and eval. Code generates and executes source code strings at runtime, or compiles functions from templates. These cases are rare but completely opaque to any static approach.
Why Runtime Signals Change the Confidence Calculus
A function that static analysis labels dead but that appears in a production runtime trace is not dead. Something went wrong in the static analysis, such as unresolved dynamic dispatch, an untraced reflection pattern, or a missed entry point. The runtime trace is ground truth for what actually executed.
Conversely, a function that static analysis labels dead and that has no production trace for 60 or 90 consecutive days is a much stronger removal candidate than one deemed dead by static analysis alone. The runtime trace confirming absence supplies a second, independent signal.
This is the reasoning behind YAGNI's confidence tiers. Static analysis alone creates a list. Cross-validating it with runtime signals turns that list into findings with differentiated confidence, reserving the high-confidence tier for cases where both signals agree. That tier is suitable for automated pull requests. The remaining findings go to human review.
A Note on Whole-Program vs. Incremental Analysis
Some language toolchains support whole-program analysis: one pass builds the complete call graph for the entire compiled artifact, including all dependencies. This produces the most accurate static call graphs because it can follow calls into library code instead of stopping at module boundaries.
For performance, most tools use incremental analysis, building the call graph module by module and linking them. It is faster but may miss call chains that resolve only when the full program is assembled.
YAGNI performs whole-project analysis within the repository boundary and uses framework-aware heuristics for calls into third-party libraries that source cannot follow. This is not whole-program in the compiler sense, but it produces a more complete graph than pure module-boundary analysis.
The honest limitation is that YAGNI may miss an edge when a third-party library calls back into your code through a mechanism you did not configure explicitly. External callback registrations, framework lifecycle hooks, and dynamic plugin loading from external packages all fit this category. As with any opaque region, the mitigation is conservative inclusion in the reachable set until proven otherwise.
Try YAGNI on your codebase. Free plan, no credit card required.
Get Early Access