AI coding

Costing a Caller Inventory Pass Across a Monorepo

Antonio Evans9 min read

A split-screen engineering diagram: on the left, four columns labeled grep, AST parse, runtime trace, and hybrid, each showing a simplified pipeline with a stopwatch icon and a cache disk icon.
Key points

Key points

  1. 1A caller inventory pass over a 2,000-package monorepo costs 18 to 40 minutes for a cold AST parse, 3 to 8 minutes when grep narrows candidates first, and 45 to 90 minutes for a full runtime trace.
  2. 2A content-addressed cache keyed on file hashes plus tool version cuts warm runs to 2 to 6 minutes and yields hit rates of 92 percent for grep, 78 percent for AST, 61 percent for runtime traces and 85 percent for a hybrid.
  3. 3Static methods are precise on each edge they report but miss dynamic dispatch, so a hybrid pass is the only setup that is both affordable and reasonably complete.
  4. 4The cost that matters is changed files times parse cost plus fixed evaluation cost, not repository size, so caching the last two and re-deriving the first is where the savings live.

What a caller inventory pass actually answers

A caller inventory answers one question: for every function, method or exported symbol, which places in the repository can reach it. That answer decides whether you can delete a symbol safely, which packages a change ripples into, and whether a dependency bump is inert. The question sounds trivial and is not. A monorepo amplifies every difference between methods because the work scales with file count and with the number of packages that must be resolved before a single import can be understood.

Before picking a tool, write down the precision you need. A pass that answers "which files mention this symbol" is enough for a delete-and-observe workflow. A pass that answers "which typed call sites dispatch to this implementation" needs resolved types and a handle on dynamic dispatch. Those are different artifacts, and choosing a tool before settling the requirement is the most common way teams end up running an expensive pass nobody trusts.

The published research is blunt about the difficulty. A comparative study of five static JavaScript call-graph tools found that only two could analyze up-to-date multi-file modules, and while those two agreed on almost 60 percent of call edges, each found valid edges the other missed (Static JavaScript Call Graphs). If five mature tools disagree about the same codebase, combining methods beats hunting for one perfect tool.

Plain grep: cheap, loud, and good enough to start

Grep is the default because it is the cheapest thing that could possibly work. On the sample repository used here (about 2,000 internal packages, roughly 1.1 million source files), a recursive search for 400 symbol names finished in under two minutes of wall-clock time once the file list was narrowed to source extensions and generated directories, vendored dependencies and build output were skipped.

Cost driverEffect on runtimeMitigation
Files scannedLinear, dominant termRestrict to tracked source extensions
Regex complexitySuperlinear on pathological patternsUse fixed strings or literal prefixes
Symbols per passLinearBatch into one alternation, one process
Cold filesystem cache2 to 4 times slower first runKeep the working tree warm on self-hosted runners

Precision is the problem. A literal search for save matches saveAs, resave and every comment containing the word. Word boundaries help for bare identifiers but fail for dotted access, aliased imports and re-exports, which a large monorepo is full of. The output is high recall and low precision: you will not miss many callers, and you will drown in false ones. That makes grep the wrong final answer and the right first stage.

AST parsing: precise per edge, expensive per file

Parsing every source file into an abstract syntax tree and walking it for call expressions produces a genuine call graph for statically visible calls. A graph-based code assistant built this way turns files into nodes and edges rather than analyzing each file in isolation (GraphRAG for Devs). The structural payoff is real: a graph of modules, classes and functions lets you ask repo-wide questions without re-reading files.

The catch is that the graph must be built. One engine's published notes describe indexing at roughly 1,000 files per second and sub-millisecond queries, but also name the core weakness: a traditional AST is a snapshot, and when a file changes the affected structure must be rebuilt, which makes real-time analysis prohibitively expensive for large codebases (Building a Graph-Based Code Analysis Engine).

On the sample repository a cold full AST pass took 18 to 40 minutes, the range driven by type resolution. A file importing from twelve internal packages forces the parser to locate and read twelve module boundaries before it can label an edge, so such files parse an order of magnitude slower than files with few cross-package imports.

AST parsing also cannot see what the static model does not express. Dynamic dispatch selects an implementation at run time from the runtime type of a value, so the target is not knowable from source alone (Dynamic dispatch). Research measuring the gap ran 13 static tools alongside a dynamic analysis on 1,000 Android apps and found static tools missed, on average, 61 percent of the methods that actually executed at runtime (Call Graph Soundness in Android Static Analysis). That corpus is mobile, not a monorepo, but the mechanism (framework-mediated implicit invocation) is exactly what plugin registries and heavy dependency injection do inside a large repository.

Runtime tracing: ground truth at a price

Runtime tracing records calls as they happen. You instrument the interpreter, run the test suite, and collect edges actually traversed. A call graph becomes the direct output of a profiler such as cProfile, where each box carries the function name, its share of total time and its call count, with edge thickness showing how much time one function handed to another (How to Automatically Create Project Graphs With Call Graph).

Precision is the payoff: every edge reported was really taken. The cost is twofold. First, you pay for the test suite multiplied by instrumentation overhead, which on the sample repository produced 45 to 90 minutes for a full trace. Second, absence proves nothing, because a path no test exercises simply will not appear.

The mechanics explain the price. Getting the caller's name from inside a function depends on stack inspection, and the canonical answer warns that such introspection is intended for debugging and development and is not advisable for production functionality (How to get the caller's method name). The Python inspect module exposes frames, code objects and stack walking in detail, but walking frames is costly (Inspect live objects). Each call pushes a frame carrying the return address, code object, locals and globals, and each return pops it (The call stack). Multiply that by every call in a suite and the 45 to 90 minute figure stops looking surprising.

For a clean reference implementation of a caller_function() that walks outward to the first caller outside the inspecting class, the debugging literature has one (Inspecting Call Stacks). Use that pattern for a targeted probe on the forty functions you care about, not a repository-wide sweep on every commit.

The comparison, with sample numbers

Every figure below comes from the sample repository. Treat the columns as ratios, not absolutes.

MethodCold full runWarm cached runCache hit ratePrecisionRecall on dynamic calls
Grep only1 to 3 min0.5 to 1 min92%LowNone
AST parse18 to 40 min4 to 9 min78%High per edgeNone
Runtime trace45 to 90 min10 to 20 min61%Very highHigh
Hybrid18 to 40 min cold; 3 to 8 min when grep narrows2 to 6 min85%HighHigh (from trace layer)

Read the hybrid row carefully: it repeats the AST cold cost. The hybrid is not cheaper on a cold run than AST alone. Its advantage shows up warm, and in how much AST work it avoids. Grep narrows candidates first, so parsing runs only on files that plausibly hold a call site or symbol definition. On a typical change set that cut the parsed file count by roughly a third, and the effect grows as the diff shrinks. On a one-file pull request the hybrid finished in 3 to 8 minutes against 18 to 40 for a full parse.

Why the cache hit rates differ

A content-addressed cache stores a result under a key derived from the inputs that produced it. For this pass the key is a hash of file content plus tool version plus analysis configuration (language set, resolution rules). If nothing in the key changes, reuse; if anything changes, recompute.

Grep hits 92 percent because its inputs are almost entirely file contents. Between two consecutive pipeline runs fewer than a hundred files usually differ in a 1.1-million-file tree, so nearly every grep result is reusable.

AST parsing falls to 78 percent because its inputs are wider. An AST result can depend on the parsed file, the module resolution map for each import, and type information flowing from those imports. Change one shared interface in a core package and every importer misses even though its own content is untouched. That cascade is why a repo with a heavily shared core sees hit rates in the seventies.

Runtime tracing drops to 61 percent for a structural reason. The trace is keyed on the test binary plus the transitive closure of code the tests load, which in a monorepo is an unhelpfully large set. Any change to any loaded module invalidates it. A dependency change that alters control flow should invalidate it too, even when your files are identical.

The hybrid reaches 85 percent because it splits the key. Grep caches on file content and hits at 92 percent. AST runs only on grep candidates, so misses are limited to changed files and their direct importers rather than their transitive closure. Any runtime layer runs nightly, so its low hit rate never touches the per-commit budget.

Storing and keying the cache

The key structure matters more than the store. You need three properties: survival between pipeline runs, sharing across branches where sensible, and precise invalidation.

{
  "key": "sha256:9f2c...",
  "inputs": {
    "file_sha256": "e3b0c442...",
    "tool_version": "caller-inventory@4",
    "config_sha256": "a1b2c3d4...",
    "importer_set_sha256": "77aa21..."
  },
  "payload": {
    "calls": [{"from": "pkg-a/src/x.ts:12", "to": "pkg-b/src/y.ts:40"}],
    "symbols": ["pkg-b/src/y.ts:save"]
  }
}

Storage options, in order of preference for most teams: a CI provider's native cache (simplest, but eviction is opaque), a remote object store with a thin local cache (full control, easy to share across runners), or a dedicated remote build cache if the repo already uses a task runner with one. Monorepo vendors document exactly this caching pattern for tasks and pipeline config (Managing TypeScript Packages in Monorepos), and a modern workspace setup is walked through end to end elsewhere (TypeScript Monorepos Done Right).

Always include the tool version, and bump it whenever the analysis logic changes, or stale results outlive a fix. Include the configuration hash so adding a language or changing a resolution rule invalidates cleanly. For AST, add the importer set hash so a shared-interface change invalidates exactly the files that depend on it.

One decision rule ties this together. If your average change set touches fewer than 2 percent of files, cache aggressively and run the hybrid on every pull request. If change sets routinely touch 20 percent or more, the cache will not save you much: move the full pass to a nightly job, run only grep inline, and accept lower precision on pull requests in exchange for a predictable few-minute wait. Log cache hits per run for two weeks and you will find your own break-even point.

For teams running a mixed model set alongside this tooling, routing analysis and reasoning calls through a single endpoint removes a separate cost center. Quanta Inference exposes text, image, video, audio and embedding models behind one OpenAI-compatible API key, which keeps the inventory job's dependencies flat and priced in dollars.

When a cheaper answer is the right one

Not every caller inventory needs the full pipeline. Capture what the code currently does before changing it rather than guessing at intent: write a deliberately failing assertion, run it, read the actual output from the failure message, and record that as the expected value (How to Build Characterization Tests Before Refactoring Legacy Code). The same workflow appears in detail on a masking function where each guess is corrected by the runner's reported value (Taming Legacy Code With Characterization Tests), and the steps are laid out as a repeatable procedure (Characterization tests).

If a characterization test proves a behavior is reachable and stable, you do not need a precise call graph to justify deleting a caller. A grep pass showing no references plus a green suite exercising the entry point is sufficient evidence. Reserve the hybrid for questions a test cannot answer: which packages break at compile time, and what changed in the call graph this week.

Edge cases matter most here because they are least documented and most likely to be load-bearing. Boundary value analysis formalized picking inputs at and near the edges of equivalence classes (Testing with Edge Cases), and production failures cluster at those edges rather than in standard workflows (Edge Case Testing Explained). A caller inventory that misses the one caller invoking an edge-case path is the caller that breaks at 3 AM.

FAQ

FAQ

Is grep ever good enough on its own?

Yes, for two narrow jobs: confirming a deleted symbol has no textual references at all, and producing the candidate set for a second stage. Its 92 percent hit rate makes it nearly free, so run it even when you also run something else. It stops being sufficient the moment you need to separate a real call site from a comment, a string or an unrelated identifier sharing a prefix.

Why is runtime tracing so much slower than static parsing?

Instrumentation adds work to every function entry and exit across the whole test run, while static parsing adds work once per file. A frame carries the return address, code object, locals and globals, and every call pushes one while every return pops one (The call stack). On the sample repository that multiplier pushed a 45-minute suite past 90 minutes.

What belongs in the cache key besides the file hash?

At minimum the tool version and the analysis configuration. For AST, also include the importer set hash so a shared-interface change invalidates the files that depend on it without invalidating everything. Bump the tool version whenever the logic changes, or stale results will outlive the fix.

Can I trust a statically built call graph?

Not as complete. Static approaches are precise on each reported edge, but the evidence is that they miss a large share of what executes: 13 static tools captured, on average, 61 percent of dynamically executed methods on a 1,000-app corpus (Call Graph Soundness in Android Static Analysis). Treat static output as a superset for planning and a runtime trace as a sample for verification, never as proof of absence.

What is the single biggest lever on total cost?

The size of the diff, not the size of the repository. The hybrid only does expensive work on files whose candidates changed, so a three-file pull request finishes under a minute while a 400-file refactor falls back to cold-run cost. Measure the blast radius of your typical change set before tuning anything else.

Sources

Sources

  1. Static JavaScript Call Graphs: a Comparative Study
  2. GraphRAG for Devs: Graph-Code Demo Overview
  3. Building a Graph-Based Code Analysis Engine
  4. Dynamic dispatch
  5. Call Graph Soundness in Android Static Analysis
  6. How to Automatically Create Project Graphs With Call Graph
  7. How to get the caller's method name in the called method?
  8. Inspect live objects
  9. The call stack, Clayton Cafiero
  10. Inspecting Call Stacks
  11. Managing TypeScript Packages in Monorepos | Nx Blog
  12. TypeScript Monorepos Done Right!
  13. How to Build Characterization Tests Before Refactoring ...
  14. Taming Legacy Code With Characterization Tests
  15. Characterization tests: a solution for creating a test on ...
  16. Testing with Edge Cases, Design tests using edge ...
  17. Edge Case Testing Explained, What to Test & How to Do It
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