AI coding

Mapping Call Sites Before an Agent Edits a Shared Function

Antonio Evans8 min read

A wide technical diagram on a light background.
Key points

Key points

  1. 1Before an agent edits a shared function, build a call-site map: every call location, the argument shapes passed, and which callers are true entry points.
  2. 2Tag each caller as internal, exported, or dynamically invoked, because each category needs a different safety check and a different test.
  3. 3Hand-resolve aliases, re-exports, and dynamic dispatch sites, since automated tools miss them and an agent will trust whatever file you hand it.
  4. 4Write the finished map to a file in the repository that the agent must read first, so the constraint survives a cleared context window.

Why the map comes before characterization

Characterization tests document what a function does today so a refactor can be checked against captured outputs (Honeybadger). They are the right safety net, and Michael Feathers frames the legacy workflow around finding change points and test points before touching behavior (BSSw). But a characterization test only pins behavior at the boundary you chose. Pick the wrong boundary and you have pinned a private helper while the contract your users depend on sits three frames up.

The call-site map fills that gap. It answers a question a test cannot: who can observe this function, and through which doors? The freeCodeCamp walkthrough makes the same point in its prerequisites: before writing characterization tests you should know where the behavior starts, what state it changes, which external systems it touches, and which outputs may be consumed elsewhere (freeCodeCamp). That is a call-site map in plain English.

For an agent the stakes are higher than for a human. Practitioners report that LLM tools handle greenfield work far better than existing codebases with custom conventions, and that agents consistently ignore most of the surrounding code, producing copy-paste changes that miss reusable logic elsewhere (Hacker News discussion). A map the agent must consult is the cheapest correction: it puts the neighborhood in front of the model in compressed form instead of making it rediscover the neighborhood.

Here is a decision rule the first search result will not give you:

If a shared function has more than one caller in a different layer from its own definition, map call sites before you characterize. If it has exactly one caller in the same file, skip the map and write the characterization test directly.

The map is overhead. It pays off in proportion to the number of callers and the number of entry points that can reach them.

Reading the change like a code auditor

Code auditors onboard new codebases weekly or daily, and a study of eight practitioners found they align their cognitive flow with automated extraction, decomposition, and representation of codebase information rather than reading files top to bottom (arXiv). Their prototype, CodeMap, cut reliance on reading LLM responses by 79% and increased map usage time by 90% compared with a static visualization tool. The lesson: a map replaces conversation with the model, not just reading.

So the map is not a chat transcript. It is a durable artifact with structure:

  • The target: fully qualified name and file path of the function being changed.
  • Direct call sites: file, line, the exact expression, and the argument shape passed.
  • Entry points: the smallest set of externally reachable functions that can reach the target, with the path from each entry point.
  • Dynamic sites: places where the target is invoked indirectly, through reflection, registries, dependency injection, event handlers, or string-keyed maps.
  • Tests that touch it: existing tests that exercise any caller, so the agent knows which ones will move.

Agents that write a plan document and then execute it in a fresh context tend to do better than one-shot edits, which is why writing the map to a file and having the agent re-read it before editing is a repeatable loop rather than a ritual (Hacker News discussion).

Building the call graph

A call graph is a directed graph of which function calls which, typically built from profiling data or static parsing (statworx). To protect a shared function you want the static, reverse direction: who calls this, all the way out to the edges.

Two families of tooling get you most of the way:

ApproachWhat it gives youWhere it failsBest for
IDE find-usages or language serverPrecise symbol resolution, respects re-exports and importsMisses dynamic calls, string lookups, framework routingExported functions in typed languages
Static call-graph tool (AST or graph based)Whole-repo picture, path from entry pointsOver-reports, misses reflection and dependency injectionLarge codebases with many layers
Profiler-derived graphActual runtime call edges with frequenciesOnly what a run exercisesDeciding which callers matter most
Scoped search (grep or ripgrep for the symbol)Fast, language agnostic, catches string usageNo symbol awareness, aliases hide callsQuick triangulation and dynamic sites

Graph-based code assistants build a structural model of the repository specifically because file-by-file tools need repeated reads to recover cross-file relationships, which fails once you are tracing call chains (Memgraph). Projects like code-graph-rag parse the repository with Tree-sitter into a queryable graph so call chains and dependencies can be queried rather than rediscovered (GitHub). Dedicated analysis engines argue the same case: per-file ASTs cannot trace data flow between a method in one module and its caller in another (Codeprism).

A build order that works in any language:

  1. Locate the definition and record its exported name, including every re-export path.
  2. Run a symbol search for direct, statically resolvable call sites.
  3. Run a scoped text search for the bare name and any string form, to catch factories, registries, and reflection.
  4. Build the reverse graph from each call site up to the nearest exported, routed, or registered symbol.
  5. Annotate argument shapes at each direct call: which parameters are passed, which are defaulted, which are forwarded.
  6. Mark dynamic sites by hand and note why the tool could not resolve them.

Step 5 is the one agents skip. A caller that passes four positional arguments where the target declares optional fifth and sixth parameters will break silently if the agent reorders parameters. Recording shapes turns the map from a list into a constraint.

Tagging callers: internal, exported, dynamic

Not every caller carries the same risk. The tag determines what the agent may do.

TagMeaningAgent ruleTest needed
InternalSame module or same layer, not reachable from outsideMay change signature if all internal callers change in the same editUnit test at the caller
ExportedPart of a public API, a route handler, a CLI command, a worker, or a registered handlerSignature and observable behavior frozen unless a deprecation path is plannedCharacterization test at the export boundary
DynamicInvoked through reflection, a registry key, a dependency-injection container, an event bus, or a framework conventionNo signature change at all, or the registration site changes in the same commitIntegration test that drives the real dispatch

Dynamic dispatch is the trap. The choice of which implementation runs is deferred to runtime, based on the actual type or key (Wikipedia). No static tool can see a call that only exists once the type is known, so a map built purely from search will under-report, and an agent will conclude the function is private. When a function is registered under a string key, or invoked by a framework based on file naming, that site belongs in the map by hand even if the tool found nothing.

Here is what a single map entry should look like, in a format an agent can parse and a human can review. It is plain text, indented as a list so it renders anywhere:

## mask (src/sanitize/mask.ts)  [EXPORTED]
Callers:
- src/api/users.ts:88   mask(user.email)                    internal
- src/api/users.ts:94   mask(user.phone)                    internal
- src/export/csv.ts:41  mask(row.email, {keepDomain: true})  EXPORTED (public)
- src/hooks/audit.ts:12 handlerMap["mask"](record.field)     DYNAMIC (registry key)
Entry points:
- GET /users/:id         -> users.ts:88 -> mask
- GET /export.csv        -> csv.ts:41  -> mask
- audit worker (onEvent) -> hooks/audit.ts -> handlerMap["mask"] -> mask
Tests touching this: tests/mask.spec.ts, tests/export.spec.ts
Frozen: csv.ts call passes keepDomain; option must remain accepted.

That block is the deliverable. It is short, checkable, and it tells the agent exactly which calls must still compile and behave after the change.

Turning the map into an entry-point list the agent must not break

The last step converts the map into a contract. For each entry point, record one sentence: what a user does and what they observe. That sentence is what you freeze with characterization tests, because they document what the software does today and protect behavior in exactly that place (Medium).

A worked example. Suppose mask has three entry points: the user detail endpoint, the CSV export, and the audit worker. The agent is asked to change email masking so the domain stays visible. The map says the CSV export passes {keepDomain: true}, so the export already keeps the domain and must not change. The audit worker reaches mask through a registry keyed by string, so renaming the function breaks the worker silently. The endpoint is the only entry point whose observable output should change.

That yields a precise test plan: three characterization tests, one per entry point, written against current outputs before the edit. Regression risk is bounded to one of them, and any failure in the other two proves the agent overstepped. Without the map, the same agent would likely update the CSV export too, and no test would notice because none existed.

This is the reasoning behind the strangler fig approach to legacy change: incremental, contained replacement is safer than wholesale rewrite because old behavior stays in place while you move a small piece at a time (Martin Fowler). Microsoft's version stresses that a façade can route between old and new while both coexist (Microsoft Learn), and AWS frames the benefit as reduced transformation risk because changes can be tested in isolation (AWS). The call-site map is the list of seams you strangle at. A logging wrapper around the old function also gives you production call frequency, a useful tiebreaker when two entry points look equally important (Wikipedia).

Decision rule for the entry-point list: an entry point is frozen if a caller outside the repository, or a scheduled or asynchronous process, can reach it without running the code in a test. Everything else is internal and may move if all its callers move in the same change.

What to hand the agent, and how to check it obeyed

Write the map and the entry-point list into a file in the repository, not into the prompt. Prompt context is discarded, files are not, and agents can be told to read them. Then make the map a gate:

  1. The agent reads the map file before proposing any change.
  2. The agent states which entry points its change affects and which it claims to leave untouched.
  3. The agent writes or updates the characterization tests for the claimed-untouched entry points first.
  4. The agent makes the edit and runs the full set, including those untouched entry points.
  5. You review the diff against the map: any call site with a changed argument shape must appear in the plan.

Step 4 is where the map earns its keep. The most common agent failure on legacy code is not a wrong implementation but a correct implementation in the wrong place: rewriting a function the codebase has already solved elsewhere. The map shows the agent where the rest of the codebase lives.

If you run many agent sessions over the same shared code, route the work through a consistent setup rather than a fresh prompt every time. The skill library at Quanta Skills can be installed into a coding agent so the read-the-map-first step is part of the routine rather than something you retype.

FAQ

FAQ

What is a call-site map?

A document listing every place a specific function is called, the arguments each caller passes, the module each caller lives in, and which callers are entry points reachable from outside the code. It also lists dynamic invocation sites that static tools cannot resolve. It is the input you give an agent before it edits a shared function.

Why is this step needed if I already have characterization tests?

Characterization tests freeze behavior at the boundary you pick, so they only protect what you chose to observe (Honeybadger). The map tells you which boundaries are reachable from outside the system, which is the set worth freezing. Without it you may test a private helper thoroughly while an exported wrapper breaks.

How do I find callers when a language hides them behind aliases or re-exports?

Run symbol search first, then a scoped text search on the bare name and any string form, and add results by hand. Re-exports and aliases break symbol matching, and reflection or registry lookups never appear in a static graph because the implementation is chosen at runtime (Wikipedia). Hand-resolution is not optional; it is the part the agent cannot do for you.

Can an agent build the map itself?

It can draft one, and a graph-based tool speeds that up because the repository is parsed into queryable relationships instead of being re-read file by file (GitHub). It cannot be trusted to finish alone, because unverified dynamic sites are exactly the failure mode. Have the agent draft and you review the dynamic and exported rows.

How much of the map should go into the agent's prompt?

Almost none of it. Put the map in a file and have the agent read the file every session, because cleared context is more reliable than a long conversation and a file survives both. What goes in the prompt is one instruction: read the map, list the affected entry points, then plan the change.

Sources

Sources

  1. Taming Legacy Code With Characterization Tests
  2. Working Effectively with Legacy Code - Better Scientific Software (BSSw)
  3. How to Build Characterization Tests Before Refactoring ...
  4. Scaling LLMs to Larger Codebases - Hacker News
  5. Understanding Codebase like a Professional! Human, AI ...
  6. How to Automatically Create Project Graphs With Call Graph
  7. GraphRAG for Devs: Graph-Code Demo Overview
  8. vitali87/code-graph-rag: The ultimate RAG for your ...
  9. Building a Graph-Based Code Analysis Engine
  10. Dynamic dispatch
  11. Characterization tests: a solution for creating a test on ...
  12. Strangler Fig - Martin Fowler
  13. Strangler Fig Pattern - Azure Architecture Center | Microsoft Learn
  14. Strangler fig pattern - AWS Prescriptive Guidance
  15. Strangler fig pattern - Wikipedia
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