Chapter 3 of 8 · 10 min read
Import and call edges reach code that words never will
On this page
Subscriptions created through the partner API are not being prorated. The seeds — the handful of files the first, purely textual pass scores highest, and the places any further search will start from — are easy: billing/proration.py scores well on the word "prorated", partners/api.py scores well on "partner API", and a design document about partner onboarding comes third. The agent reads all three, finds nothing wrong with calculate_proration, and reports that the proration logic looks correct. It is correct. The bug is in partners/serializers.py, which builds a Subscription from an inbound partner payload and never sets trial_end, so cycle_bounds returns a cycle that starts at the trial boundary that does not exist and the proration fraction comes out as exactly 1.0. That file contains the words "partner", "payload", "serializer" and "tenant". It does not contain "prorate", "proration", "billing", or "cycle" anywhere in its 240 lines, and no amount of tuning the embedding model will make a sentence about proration match it.
The file is nonetheless two edges from a seed. partners/api.py imports it, and it constructs the model that calculate_proration reads.
Retrieval that only measures similarity between text and text can reach the two seeds. Retrieval that follows the arrows can reach the file that has to change.
Two graphs, and they cost different amounts
The cheaper of the two is the import graph, whose nodes are files and whose edges come straight out of the parse that produced the chunks in the first place. Every language has a statement that names another module, and extracting those statements is a query over the syntax tree rather than an analysis.
IMPORT_QUERY = PY_LANGUAGE.query("""
(import_statement name: (dotted_name) @mod)
(import_from_statement module_name: (dotted_name) @mod)
""")
def import_edges(path: str, source: bytes) -> list[tuple[str, str]]:
tree = PARSER.parse(source)
edges = []
for node, _ in IMPORT_QUERY.captures(tree.root_node):
module = source[node.start_byte:node.end_byte].decode()
target = resolve_module(module, from_path=path) # -> file path or None
if target is not None:
edges.append((path, target))
return edgesThe whole of it is resolve_module. Turning from billing.calendar import cycle_bounds into billing/calendar.py is trivial; turning import { parseAmount } from "@acme/money" into a file requires reading tsconfig.json path aliases, the workspace layout, and possibly a package.json exports map, and getting it wrong means edges that point nowhere. Unresolved imports are the single largest source of holes in an import graph, and they are worth counting: on one TypeScript monorepo, 11% of import statements failed to resolve on the first pass, almost all of them path aliases, and fixing the alias handling moved graph coverage from 89% to 99.6% in an afternoon.
The call graph is finer and more expensive. Its nodes are symbols rather than files, and its edges say that this function calls that one, which requires knowing what self.store.put(...) refers to, and that requires types. Building it properly means running the language's own analysis — go list, a TypeScript program, a Python type checker, a Java compiler plugin — and emitting an index. This is not new machinery invented for retrieval; it is the same machinery that has powered "go to definition" for two decades, and the modern serialisation formats for it — SCIP, the Source Code Intelligence Protocol, and its predecessor LSIF, the Language Server Index Format, both of which are on-disk records of every symbol in a repository and every place it is referenced — exist precisely so that an indexer can produce a symbol graph once and many tools can consume it. A retrieval system that emits SCIP from the compiler and reads it back gets a resolved call graph almost for free, and one that tries to approximate call edges by matching identifier names gets an edge from every get in the repository to every other get.
Somewhere in between is a cheap and surprisingly effective approximation: an edge from a file to every file that defines a symbol the first file names, restricted to symbols the first file also imports. It gets inheritance, direct calls and type references, misses everything dynamic, and requires no compiler.
How far to expand before precision collapses
The obvious algorithm is a breadth-first walk from the seeds with a budget, meaning that you collect every file one edge away from a seed before considering anything two edges away, so that the walk fans outwards in rings rather than following one chain to its end.
def expand(seeds: list[str], graph: Graph, depth: int, budget: int) -> list[str]:
seen, frontier, out = set(seeds), list(seeds), list(seeds)
for _ in range(depth):
nxt = []
for node in frontier:
for neighbour in graph.neighbours(node):
if neighbour in seen or graph.degree(neighbour) > 200:
continue
seen.add(neighbour)
nxt.append(neighbour)
out.append(neighbour)
if len(out) >= budget:
return out
frontier = nxt
return outThe interesting parameter is depth, and its effect is not gradual. Measured over 120 merged pull requests on a 4,300-file Python monorepo, where the ground truth is the set of files each PR touched:
| Depth | Candidate files (median) | Recall of edited files | Precision | Tokens if all fed in |
|---|---|---|---|---|
| 0 (seeds only) | 12 | 0.41 | 0.58 | ~9,000 |
| 1 | 47 | 0.79 | 0.31 | ~38,000 |
| 2 | 214 | 0.86 | 0.09 | ~170,000 |
| 3 | 901 | 0.89 | 0.02 | ~720,000 |
The first hop is where the value is. It nearly doubles recall for a fourfold increase in candidates, and it is the hop that reaches partners/serializers.py. The second hop buys seven points of recall for a fivefold increase in candidates, and the third buys three points for another fivefold. Past depth one, the candidate set grows roughly as the average out-degree, which is the number of files a file imports, averaged across the repository — about 4.5 here — so each additional hop multiplies the set by about five while the number of genuinely relevant files it adds stays in the single digits.
Expansion also amplifies whatever the seeding step got wrong, which is a property worth internalising before tuning anything else. Each incorrect seed contributes its own neighbourhood — about 4.5 files at depth one on this repository — so a seed set of twelve files that is half wrong produces roughly 27 irrelevant candidates at the first hop, and those candidates are not randomly bad, they are coherently bad: they all sit around a plausible wrong file, which makes them look like corroboration. A retriever that returns eight files from the same wrong subsystem reads as confident. The practical consequence is that improving seed precision pays roughly five times over at depth one, which makes the literal-matching and symbol-name work of the previous chapters worth more than it looks in isolation.
Not every edge deserves the same weight, either. An edge created by a direct call to a named function is stronger evidence of relatedness than an edge created by importing a type for an annotation, which in turn is stronger than an edge to a constants module that half the repository imports. Recording the edge kind — call, type reference, inheritance, re-export, test-of — costs nothing at extraction time and gives the ranker something to work with later, and the cheapest version of that idea is simply to record the number of distinct symbols the importing file actually uses from the imported one, since a file that uses eleven symbols from another is related to it in a way that a file using one enum value is not.
This is why the expansion has to be a candidate generator feeding a ranker rather than a context filler. Depth two at 214 files is a perfectly reasonable candidate pool to score and cut down to fifteen; it is an absurd thing to paste into a prompt. Teams that skip the ranking step discover the depth ceiling as a cost problem — 170,000 tokens per task, most of it unrelated — and conclude that graph expansion does not work, when what does not work is using a candidate set as a context set.
Direction matters as much as distance
An import edge is directed, and the two directions answer different questions. Following edges forward, from a file to what it imports, tells you what a piece of code depends on, which is what you need in order to understand its behaviour. Following them backwards, from a file to everything that imports it, tells you who depends on this code, which is what you need in order to change it safely.
Which direction dominates depends on the task, and the split is close to clean. A task phrased as a behaviour question — why does proration return 1.0 for partner subscriptions — wants forward edges, because the answer lives in the callees. A task that changes an interface — rename this field, add a required parameter, change this return type — wants reverse edges, because every caller is a file that must change for the code to compile. calculate_proration has fourteen call sites across seven modules, and a task that alters its signature must retrieve all fourteen or produce a patch that does not build.
Tests are a special case of the reverse direction and they are unusually valuable. A test file imports the thing it tests, so it is always a reverse neighbour, and it carries two things that are hard to get anywhere else: an executable statement of the current contract, and a file that will have to change if the contract changes. Retrieving tests/test_proration.py for a proration task gives the agent the fixture values, the expected outputs, and the shape of the assertions, which together constrain the patch more tightly than any amount of prose about proration. Some teams weight test files down on the grounds that they are not production code and take up context, which is the wrong instinct on interface changes; the right adjustment is to prefer the test file that imports the seed directly over the twelve test files that import it transitively through a fixture module.
A retriever that expands only forward will systematically miss callers, and missing callers is the specific failure mode that will come back in the measurement chapters, because it is invisible to any evaluation whose labels were written by thinking about the topic rather than by looking at a diff. The cheap version of the fix is to expand in both directions and give reverse edges a higher weight when the task text contains an interface-changing verb — rename, add a parameter, change the type, deprecate. The honest version is to expand both directions always and let the ranker sort it out.
Edges that lie
Graph expansion inherits every inaccuracy in the graph, and there are four sources worth watching for.
Stale indexes are the most mundane. A repository merging 40 pull requests a day, indexed nightly, has an average edge age of twelve hours, which means files that were renamed this morning still exist in the graph and their new names do not. Expansion from a seed near the rename walks into nodes whose chunks no longer correspond to anything, and the agent is handed code that is not in the working tree. Incremental indexing on merge fixes it; the cheaper mitigation is to validate that every retrieved path still exists before it enters the prompt, and to log how often that check fails, since a rate above about 2% means the index cadence is wrong.
Hub files are the most damaging to precision. A Python package's __init__.py that re-exports 60 symbols, a TypeScript barrel file — an index.ts whose only job is to re-export a directory's public surface so that callers can import from one path — a generated API client of 30,000 lines that imports every model in the repository: each of these has a degree, the count of edges attached to a node, in the hundreds, and a BFS that walks through one arrives at everything. The degree cap in the expansion above is not a refinement; without it, one hop through a barrel file turns a candidate set of 47 into a candidate set of 600. Suppressing traversal through nodes above a degree threshold, or excluding pure re-export files from the graph entirely and rewriting their edges to point at the real definitions, is the difference between a usable expansion and a random sample of the repository.
Generated code adds a third distortion, in that its edges are real but its files are never edited. A protobuf-derived module genuinely imports and is imported, and it will be pulled in at depth one from almost any seed in a service that uses it, occupying candidate slots for a file whose correct treatment is to regenerate it. Marking generated paths and excluding them from candidates — while keeping their edges for traversal, since they are the bridge between a handler and a model — recovers those slots.
The fourth is the one this chapter cannot fix. Every edge described here is an edge some parser could see: a written import, a resolved call, an inheritance relationship. Repositories are full of relationships that no parser can see, because the connection is made at runtime through a string, an annotation, or a container. A handler registered by decorator and dispatched from a dictionary is not one hop from its dispatcher, or two, and this is the case where turning the depth up spends the entire context budget without ever crossing the gap. That failure has its own shape and its own repair, and it is the subject of the next chapter.
Repo-scale code retrieval — read the current version at https://daviddominguez.dev/learn/en/repo-scale-code-retrieval/import-and-call-edges-reach-code-that-words-never-will