AI coding

A Call Graph Diff an Agent Runs Before It Edits

Antonio Evans7 min read

A terminal split in two halves: on the left a table of callers before and after an edit with one new indirect caller highlighted, and on the right a non-zero exit code with the patch left unwritten.
Key points

Key points

  1. 1A caller-list check belongs before the edit, not after: record callers, apply to a scratch copy, rebuild, diff.
  2. 2Any caller, direct or indirect, that appears only after the change means the patch widened its blast radius.
  3. 3Static call graphs are always approximations, so the diff flags suspects, and a human or test decides.
  4. 4Exit non-zero and discard the patch when the diff is non-empty; do not let the agent report success.

Why the check belongs before the edit

Most agent workflows run verification after code is written: lint, tests, then a review pass. That order is convenient because the agent already has a patch to measure, but it is the wrong order for impact analysis. By the time a post-edit check runs, the agent has already committed to a change, and the cheapest correction is a revert, which costs a full rewrite and usually a second model round trip.

Move the caller check in front of the edit and the shape of the loop changes. The agent first asks: if I touch these symbols, which functions will call them? That question is answerable without writing a single line of new code. It costs one graph build and one query per symbol. If the answer is stable, the edit proceeds. If the answer changes once the patch is applied to a scratch copy, the agent stops with a non-zero exit instead of handing back a patch that quietly adds a new caller from a module nobody reviewed.

There is a real precedent for keeping this kind of check in the calling code rather than the callee. Python's inspect module can recover the caller's frame at runtime, which is how libraries that vary behavior by caller name do it, but the accepted answer on Stack Overflow warns that this introspection is meant for debugging and development, not production logic: the inspect answer. The lesson transfers. Caller relationships are better established as an artifact the tooling produces than as something a running function discovers about itself. An agent building a static graph is doing the same thing, in the safe direction.

Build the graph and record the caller set

The first step is a static graph over the repository. A static call graph is a directed graph where nodes are functions and edges exist when one function calls another, as described in the introduction to a JavaScript call graph study. For Python, you can build one from the standard library's ast module plus NetworkX, which is the approach in this worked example. For a maintained tool, pyan is a static call graph generator for Python, and grepai trace offers a callers query with JSON output suited to agents.

A minimal query layer looks like this:

import ast, networkx as nx

def build_graph(paths):
    graph = nx.DiGraph()
    for path in paths:
        tree = ast.parse(open(path).read())
        funcs = {n.name: n for n in ast.walk(tree) if isinstance(n, ast.FunctionDef)}
        for fname, node in funcs.items():
            graph.add_node(fname)
            for call in ast.walk(node):
                if isinstance(call, ast.Call) and isinstance(call.func, ast.Name):
                    graph.add_edge(fname, call.func.id)
    return graph

def direct_callers(graph, symbol):
    return {src for src, dst in graph.in_edges(symbol)}

def transitive_callers(graph, symbol, depth=3):
    seen, frontier = set(), {symbol}
    for _ in range(depth):
        frontier = {src for node in frontier for src, _ in graph.in_edges(node)}
        new = frontier - seen
        if not new:
            break
        seen |= new
    return seen

The transitive_callers function is the important one. A direct caller set tells you who calls the symbol today. A caller set with depth two or three tells you who is reachable through another function, which is exactly the kind of indirect breakage a careless signature change introduces. Choose a depth and keep it constant between the before and after runs, because comparing a depth-three before set against a depth-two after set produces phantom removals.

Two properties are worth registering before you trust the numbers. First, static call graphs over-approximate, because of higher-order functions and polymorphism: the pyan-based write-up says as much. Second, they also under-approximate. A study of Android static analysis ran 13 tools and a dynamic analysis over 1000 apps and found the tools failed to capture, on average, 61% of the dynamically executed methods, with high precision a synonym for high unsoundness: the Android call graph paper. Plan for both directions. A caller set is evidence, not a proof.

Take the snapshot and apply to a scratch copy

The agent must not edit the working tree before the check passes. Copy the relevant tree to a scratch directory, apply the patch there, and rebuild.

StepWhere it runsArtifact produced
1. SnapshotWorking treebefore.json (caller sets per symbol)
2. EditScratch copypatch.diff
3. RebuildScratch copyafter.json
4. DiffAnywherecaller-diff.json plus exit code
5. DecideAgent loopcommit or discard

before.json is a plain map from symbol name to a sorted list of callers. Write the same map after the patch, using the same depth, the same file globs, and the same parser version. If the agent uses a vendored graph tool in one run and a different one in the next, you are diffing tools, not code. Pin the tool.

The scratch copy also protects against a subtler failure. An agent that edits files in place and then needs to discard its work has to reconstruct the original, either from a stash or from version control. Neither is guaranteed to be available in the sandbox. Copying first makes the discard cost one rm -rf.

The diff and the exit code

The diff is a set comparison per symbol. Callers that are present before and absent after are usually fine, because they indicate the symbol shrank. Callers that are absent before and present after are the ones that matter, because they indicate a new dependency on something the agent changed.

before, after = load("before.json"), load("after.json")
regressions = {}
for symbol in before:
    added = set(after[symbol]) - set(before[symbol])
    if added:
        regressions[symbol] = sorted(added)

if regressions:
    print(json.dumps({"new_callers": regressions}, indent=2))
    discard_patch()
    raise SystemExit(3)

Exit code 3 is arbitrary but should be stable and documented, so the orchestrator can distinguish "new callers appeared" from "the build failed" and "the tool crashed." Keep the diff document as evidence. The reviewer reads it, not the agent's summary of it, which is the same principle approval testing relies on when it makes a reviewer read a rendered diff rather than a pile of assertions: the approval testing guide. A patch that changes behavior silently is even harder to catch from a text review than a rendered output, so the machine-generated caller diff earns its place.

What the diff will and will not catch

Be explicit with the team about the scope. The caller diff catches new direct calls to a touched symbol and, at depth, new paths that reach it. It does not catch a caller whose call reaches the symbol through a function pointer, a dictionary lookup, a decorator, or a framework callback. Function pointer resolution needs dataflow analysis that a lightweight AST walker does not have: the Stack Overflow thread on function pointer call graphs makes the distinction plain.

Reflection is the same story in dynamic languages. Python's own inspect module is at the edge here, since a call made by string name leaves no AST edge at all: the inspect docs. If a repository leans on reflection or dependency injection, lower the trust in the diff and increase the depth so the approximation widens in the safe direction.

WebAssembly, and by extension any build target with indirect call tables, adds a third bucket: the paper on WebAssembly call graph challenges identifies indirect calls via a mutable function table as a distinct difficulty and shows that all four analyses it studied were unsound: the WebAssembly call graph paper. If your repository ships a compiled target, run the diff on the source and treat the compiled layer as unverified.

Soundness and precision trade off in Python tooling too. PyCG is described as the state-of-the-art for Python, and a later tool, Jarvis, reports being at least 67% faster with 84% higher precision and at least 20% higher recall on its benchmark: the Jarvis paper. Those numbers come from that paper's own evaluation, not from an independent test. A separate paper proposes PyPt and reports beating PyCG on soundness and completeness over 99 programs and six real projects: the PyPt abstract. Read both with the same posture: the graph you build is as good as its approximations, and the diff only tells you what the approximations disagree about.

Where the check pays off most

Some edits deserve the full before-and-after run and some do not. A decision rule that works well:

  • Run the full diff when the patch changes a function signature, removes a parameter, changes a return type, or moves a symbol between modules.
  • Run the direct-caller check only when the body changes but the signature does not.
  • Skip the check when the patch touches only tests, fixtures, or documentation.

A worked example with numbers. Suppose the agent plans to rename parse_config to load_config. The before snapshot at depth two returns 4 direct callers and 11 transitive callers. After the patch, the snapshot returns 4 direct callers under the new name and 12 transitive callers, because a module the agent never opened imports the old name through a re-export. The diff names that module. The agent exits 3, discards the patch, and rewrites with a compatibility alias. That is one wasted patch instead of one broken deploy. Without the check, nothing in the patch would have failed a unit test, because the new module is only exercised at import time in a path the tests do not load.

The pattern generalizes to TypeScript monorepos, where a symbol can be re-exported through a path alias and re-imported several layers away. Packing your packages into isolated modules reduces the chance of an accidental cross-package import, but it does not remove the need to know who will call the changed symbol: a monorepo guide on package boundaries covers path aliases, project references and workspaces as the structural answers.

The general principle is well established in program analysis. A static call graph lets you identify which functions may be affected by a change and evaluate reachability, which is what a caller list is: the minimal call graph post. The agent's contribution is not a new analysis idea. It is placing the analysis before the write instead of after the compile.

Wiring it into the agent loop

Put the check in the agent's tool list, not in its prompt. A tool the model can call and read is more reliable than an instruction it has to remember, and a non-zero exit from a tool is harder to explain away than a sentence in a prompt.

The plumbing is short. The tool exposes two functions, snapshot and check, each returning JSON. The agent is told to call snapshot with the planned symbol list before invoking any write tool, and check after applying the patch to a scratch copy. If check exits non-zero, the agent has to amend the plan rather than retry the same patch. Retrying a patch that widened the caller set produces the same diff, and each retry costs another graph build.

Skills help here. A skill that bundles the graph builder, the depth constant, the exit codes and the scratch-copy routine into one installable unit means every agent in the team runs the same check, with the same depth, against the same parser. Put the skill in a coding agent and the check travels with it. See Quanta Skills for the skill library pattern and how to install one into a coding agent.

FAQ

FAQ

Why run the caller check on a scratch copy instead of in place?

Because a failing check means discarding the patch, and discarding is trivial only if the working tree was never touched. A scratch copy also keeps the before snapshot honest, since the filesystem the graph reads from never contains both states at once.

Is the depth-limited caller set sound?

No. Static call graphs both over- and under-approximate: the Android study found tools missed most dynamically executed methods, and Python tools disagree on which edges exist. Treat the diff as a flag for human review, not a proof that nothing new can call the symbol.

What exit code should a failed check return?

Any stable code works as long as it is documented and distinct from build failure and crash. The article uses 3. The orchestrator needs that distinction because a crash should be retried, while a widened caller set should be fixed by editing the plan.

Will the diff catch callers made through reflection or function pointers?

Not reliably. Calls made by string name, decorator or function pointer often leave no edge in an AST-based graph, so check depth three or four when the repository relies on them. Compiled targets such as WebAssembly need a different treatment entirely.

Does a clean diff mean the patch is safe to merge?

It means no new caller was discovered by the graph build. Tests, type checks and review still apply. A clean caller diff removes one failure mode, and it is worth running every time because the failure mode it removes is invisible to a text review.

When you need the graph build, the skill and the agent loop to share one runtime, Quanta Inference gives you every model kind through one API key with OpenAI-compatible routing and prepaid US dollar pricing, so the analysis tool and the agent that calls it live behind the same endpoint.

Sources

Sources

  1. How to get the caller's method name in the called method?
  2. Static JavaScript Call Graphs: a Comparative Study
  3. Generating a call graph using NetworkX and the ast module
  4. The Python static call graph generator. pyan3 on PyPI. ...
  5. Call Graph Analysis | grepai
  6. A Minimal Static Call Graph for Python Programs
  7. Call Graph Soundness in Android Static Analysis
  8. Approval Testing and Golden Master Testing Guide
  9. how do call graphs resolve function pointers?
  10. Inspect live objects
  11. Studying the Challenges of Call Graph Construction for ...
  12. Scalable and Precise Application-Centered Call Graph ...
  13. A practical call graph construction method for Python
  14. Managing TypeScript Packages in Monorepos | Nx Blog
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