AI coding

Finding Callers a Static Graph Misses: Dispatch and Reflection

Antonio Evans7 min read

A dark map of a codebase drawn as a subway network: solid lines are the static call graph, and faint dotted lines fan out from a few stations into unlabeled territory.
Key points

Key points

  1. 1Static call graphs and grep only see call sites that are written literally in source, so callers arriving through interfaces, hooks, config strings, dependency injection or reflection stay invisible.
  2. 2You find those callers by instrumenting the dispatch and reflection entry points (the few functions that resolve a name to a callable) and logging, at runtime, which caller invoked which name.
  3. 3A runtime pass over real test and staging traffic gives you a caller list you can freeze into a characterization test that fails when a caller would break.
  4. 4The decision rule: instrument the choke points, not the call sites, and guard the resolved names, not the import graph.

Why the map has holes

Every static call graph is built from syntax. A tool parses files, sees service.persist(order), and draws an edge from the calling function to persist. That works exactly as long as the compiler or parser can decide which persist is meant 1. A visual call graph produced from profiler data shows real calls, but only for the run it profiled; the map is a snapshot, not a guarantee 2.

Dynamic dispatch breaks the assumption. The implementation is chosen at run time based on the runtime type of the receiver, so the source shows a call to a name, not to a function 3. Polymorphism is the point: a File object and a Database object both expose StoreRecord, and the caller does not know or care which one it holds 4. A graph that picks one or draws edges to all implementations is guessing.

This is not a small edge case. A study that ran 13 static analysis tools plus one dynamic analysis over 1,000 Android apps found that, on average, the static tools failed to capture 61% of the methods that actually executed at run time. Apps built around external frameworks were the worst offenders, and the authors note that higher precision in call graph construction goes hand in hand with more unsoundness 5. Callback-driven and event-driven frameworks invoke application methods implicitly; the call lives in the framework, which is often not analyzed along with the app 6.

Your web service has the same shape. The framework calls your controller. The dependency injection container constructs your class and calls its constructor. The plugin loader calls handle. None of those call sites appear in your repository, because they sit in a library.

The five kinds of invisible caller

Before writing any tooling, it helps to know what you are hunting. In practice, invisible callers fall into five families, and each resolves a name at run time rather than at parse time.

FamilyWhere the call is writtenWhy grep misses itTypical example
Interface or abstract methodA call to a base type or interfaceThe edge points at the declaration, not the implementationhandler.process(event) with five implementers
Plugin or hook registryA registry populated at import timeThe call is hooks[name](payload)register("on_save", fn) then invoked by name
Config or database stringYAML, env var, DB rowThe name exists as data, not codejobs: [reindex, notify]
Dependency injectionContainer wiringConstruction and invocation happen in the containerConstructor injection of a service
Reflection or attributesgetattr, MethodInfo.Invoke, decoratorsThe method name is a string argumentgetattr(obj, f"handle_{kind}")()

The important property is that all five converge. Somewhere in your code or a framework you control, there is a small number of functions that turn a name into a call: the registry's dispatch, the container's resolve, the reflection helper's invoke_named. Those are the choke points. Instrument the choke points and you see every caller, including ones no static tool can find.

Instrumenting the choke points

The Python inspect module can answer "who called me" by walking the stack: inspect.currentframe() gives the current frame, inspect.getouterframes(frame, n) returns the frames above it, and index 1 is the direct caller 7. The standard documentation lists f_back, f_code, f_globals and f_lineno among the frame attributes you can read 8. Walking f_back is how the debugging literature identifies an active caller during tracing 9, and the frame chain is exactly the structure an interpreter maintains for return addresses 10.

Note the caveat that comes with the technique: stack introspection is meant to help debugging and development, and relying on it for production functionality is not advisable, because it is slow and couples your code to interpreter internals 7. Use it in a recording mode that you switch on in tests and staging, never on the hot path in production.

A working recorder for a name-based registry looks like this:

import inspect
import json
from collections import defaultdict

CALLERS = defaultdict(set)

def record(registry_name, target_name, depth=2):
    frame = inspect.currentframe()
    outer = inspect.getouterframes(frame, depth + 1)
    caller = outer[depth]
    CALLERS[registry_name].add(
        (target_name, caller.function, caller.filename, caller.lineno)
    )

def dispatch(registry, name, *args, **kwargs):
    record("dispatch", name)
    return registry[name](*args, **kwargs)

def dump(path="dispatch_callers.json"):
    serializable = {
        k: sorted({(t, f, file, line) for t, f, file, line in v})
        for k, v in CALLERS.items()
    }
    with open(path, "w") as fh:
        json.dump(serializable, fh, indent=2, sort_keys=True)

Three things make this useful. It records the caller's function, file and line, so the result is actionable the same day. It keys by the resolved name ("reindex", "on_save"), which is the thing a static tool cannot see. And it writes a sorted, JSON-serializable form, so two runs can be diffed.

For reflection, wrap the lookup instead of the call. In Python that means a helper around getattr; in .NET, around MethodInfo.Invoke; in Java, around Class.getMethod(...).invoke(...). One wrapper per language, and every reflective caller flows through it.

Running the pass and reading the result

A single test run will not exercise every caller. Run the recorder across the integration suite, a replay of representative staging traffic, and any scheduled-job harness. Then merge the JSON files.

Do not stop at "which names are called." The question that matters for a guard is "which callers depend on which name." Filter the merged output to the callers you care about: the module you are about to change, or the registry entries with the fewest callers, since a seldom-used entry is the one most likely to be broken by a refactor and the one no test covers.

Then apply a simple triage rule. For each resolved name, look at three numbers: how many distinct callers, whether any caller is outside the owning package, and whether any caller appeared only in staging traffic and not in tests. Any entry that satisfies the second or third condition is a candidate for the guard. Those are the callers that would break silently, because no test would notice.

Two practical cautions. First, stack depth varies with wrappers and decorators; assert on caller.function and caller.filename rather than a fixed depth index, or walk until you leave the dispatcher module. Second, deduplicate across runs before comparing, or a test suite that runs twice will look like a changed caller set.

Freezing the list into a characterization test

A characterization test, in Michael Feathers's sense, documents what the code does today rather than what it should do. The working recipe is: write an assertion you know is wrong, run it, read the actual value from the failure, then update the test to assert that value and repeat 11. Honeybadger's walkthrough of the same technique adds the useful discipline of guessing first and letting the failing output correct you, rather than reasoning about the expected value up front 12. The point is to make current behavior visible before you change it, not to freeze every line forever 13.

The caller list is a behavior. Freeze it the same way:

import json

def test_dispatch_callers_unchanged():
    recorded = json.load(open("dispatch_callers.json"))
    current = json.load(open("dispatch_callers.current.json"))
    assert current == recorded, "caller set changed; inspect diff"

The test reads two artifacts: the reviewed baseline you committed, and the output of the instrumented run in CI. When the diff appears, the failure message tells the reviewer exactly which caller was added or removed, which function, which file, which line. That is a reviewable change, not a mystery.

Treat additions differently from removals. An added caller is a new dependency on a name: acceptable, but it should be accompanied by a baseline update in the same commit. A removed caller in a refactor that claims to be behavior-preserving is a red flag, because you just lost a user of that name. Write both rules into your contributing guide so reviewers know which way to read the diff.

A worked example with numbers

Suppose a service has a plugin registry with 40 registered names, 6 of which are called through dispatch. You are about to change the signature of one plugin, reindex. You open the repository and search for reindex. You find three call sites: the registration, a unit test, and a doc example. You change the signature and update the three, and the tests pass.

Then the recorder tells a different story. After merging three runs (integration suite, a two-hour staging replay, and the nightly job harness), the baseline for reindex shows five distinct callers: the unit test, a CLI command module, a webhook handler, a scheduled job module, and one caller that appeared only in staging traffic, in a legacy admin path. Three of the five were invisible to search because the CLI and webhook resolve the plugin by name from config.

Without the guard, you would have shipped a signature change that broke the admin path first (lowest traffic, latest to notice) and the webhook second. With the guard, the baseline diff for reindex changes from five to four callers, and the failing test points at the admin path file and line. The fix takes ten minutes instead of an incident.

The numbers to keep in mind: 6 of 40 names called at all, 5 callers on one name, 3 of them invisible to grep. That ratio is the reason to record once and guard forever, rather than to grep harder.

Connecting this to the rest of the map

Caller discovery and call graph tooling are complements, not substitutes. Graph-based assistants built on parsed code, such as code-graph-rag, produce a structural map of the repository and answer questions about it 14. They share the limitation described here: the graph is built from syntax, so the reflection and registry edges are missing unless the tool models them explicitly 15. LLM-based comprehension tools have their own documented gap, and auditors report that they cannot supply the business logic needed to understand a codebase on their own 16.

The pattern that works is to use the static graph for what it does well (file structure, imports, direct calls) and the runtime recorder for what it cannot see. If you are migrating behind a strangler fig, the invisible caller list is also your seam detector: any name with callers on both sides of the boundary is a switch-over point, and the baseline is the list of callers that must keep working during the transition 17.

Where a coding agent is doing the refactor, give it the baseline file as context. A recorded caller list is exactly the kind of ground truth an agent otherwise lacks about a brownfield codebase 18. If you want an agent to carry out the recorder setup, run the pass, and write the guard test in your repository's conventions, Quanta Skills is built for that kind of installable procedure.

FAQ

FAQ

Can I use this approach in a language other than Python?

Yes. The idea is language-independent: find the one or two functions that resolve a name or type into a callable, wrap them, and log the caller. In Java that is reflection plus the dispatch point in your framework; in C# it is MethodInfo.Invoke and the DI container; in JavaScript it is a registry lookup or obj[name]. Python is convenient because inspect exposes the caller frame directly 8.

Does stack introspection belong in production code?

No. The technique is intended for debugging and development, and relying on it for production functionality is not advisable because of cost and coupling to interpreter internals 7. Record in tests, CI and staging, then commit the artifact.

How is this different from a coverage report?

Coverage tells you which lines ran. The recorder tells you which caller invoked which name through a dynamic mechanism, which is what breaks when you change a signature or rename a registry entry. A line can be covered while its caller set is unknown, so the two artifacts answer different questions.

Won't the guard fail constantly as the codebase grows?

Only if additions are noisy, and they are not, because dynamic callers are rare relative to direct calls. Make baseline updates part of the same commit as the change that causes them, and treat removals as reviewable events. The test becomes a record of intent rather than a nuisance.

What if the baseline captures a caller I want to delete?

That is the guard doing its job early. Confirm in the recorder output whether the caller appears in tests only or in staging traffic, decide whether the caller is dead or merely undocumented, and remove it explicitly, with the baseline updated in the same commit.

Sources

Sources

  1. How to Automatically Create Project Graphs With Call Graph
  2. Dynamic dispatch
  3. Call Graph Soundness in Android Static Analysis
  4. How to get the caller's method name in the called method?
  5. Inspect live objects
  6. Inspecting Call Stacks
  7. The call stack, Clayton Cafiero
  8. Characterization tests: a solution for creating a test on ...
  9. Taming Legacy Code With Characterization Tests
  10. How to Build Characterization Tests Before Refactoring ...
  11. vitali87/code-graph-rag: The ultimate RAG for your ...
  12. Building a Graph-Based Code Analysis Engine
  13. Understanding Codebase like a Professional! Human, AI ...
  14. Strangler Fig Pattern: A Practical Guide to Legacy Migration | Catio
  15. AI can't handle your legacy codebase? This might be why.
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles