The dependency graph is what everything else walks. Risk analysis, dead-code detection, blast radius, execution flows, and the wiki itself all read it.
Most graphs will tell you that A calls B. Fewer will tell you how
that was worked out. Repowise stamps every call edge with the strategy
that produced it, so a fact and a guess do not arrive looking the same.
Built at index time with no LLM calls and no network. An API key changes the prose in the wiki. It never touches the graph, which is why the graph is reproducible.
Two tiers, one graph
Tree-sitter parses every file into:
- File nodes: one per source file, plus package nodes, plus lightweight external nodes for third-party packages so a dependency is visible without being documented.
- Symbol nodes: functions, classes, methods, and interfaces.
Both tiers live in a single NetworkX DiGraph, so you can traverse from
file to file, file to symbol, or symbol to symbol in one query.
Two families of edge share the graph and are deliberately kept apart: structure the parser can see (imports, calls, inheritance) and structure only history can see (files that keep changing together without importing each other). A co-change edge is real signal, but treating it as a dependency would put "these two files were edited in the same commit" into your import graph.
Consumers do not hand-roll a filter over the vocabulary. Three named views are derived from it and shared:
| View | Answers |
|---|---|
FILE_DEPENDENCY_EDGE_TYPES | "what does this file depend on?" Used for communities, cycles, coupling |
SYMBOL_USE_EDGE_TYPES | "what reaches this symbol?" Containment excluded, since a class holding a method is not a use of it |
REACHABILITY_USE_EDGE_TYPES | "does anything use this at all?" The dead-code view |
Before those views existed, four places each wrote their own edge filter and two of them silently counted co-change edges as imports.
Two stages, and they fail differently
An edge is two claims made by two pieces of machinery, and they break in opposite directions.
Capture happens first: the parser has to notice that a call was written at all. That is a tree-sitter query per language, listing the source shapes that count as a call site. Go alone lists a plain call, a method call, a package-qualified call, a chained call, and a function passed as an argument, and the last of those is captured as a reference rather than a call, because passing a handler is not invoking it.
A shape no query matches is invisible. Not low confidence, not unresolved: absent. Nothing downstream can recover it, because nothing downstream knows the call was there.
Resolution happens second: given a captured site, work out what the
name points at. repo.save(draft) hands you the name save and a
receiver spelled repo, and the job is to turn that into one
declaration in one file.
| Fails when | Costs you | How you find out | |
|---|---|---|---|
| capture | nobody wrote a query for that call shape | recall: the edge does not exist | nothing internal can tell you, so it takes an outside answer key |
| resolution | the receiver cannot be typed, or the name is ambiguous | precision if it guesses, recall if it declines | the origin on the edge names the strategy that answered |
That asymmetry sets the design. A missed capture is silent, so it is measured against a compiler rather than against ourselves. A bad resolution is loud, so every edge is stamped with the strategy that produced it.
Every call edge says how it got there
Each calls edge carries a resolution origin: the named strategy
that produced it, drawn from a closed vocabulary of 29 values. Each
origin carries exactly one fixed confidence.
| Confidence | Origin | What was actually established |
|---|---|---|
0.95 | same_file | The callee is defined in the calling file. A certainty |
0.95 | self_scope | self / this, a method on the caller's own class |
0.93 | receiver_same_file | The receiver names a type declared right here |
0.90 | import_scoped | The name was imported from the file that defines it |
0.90 | same_package | A sibling file that needs no import (Go, JVM) |
0.88 | receiver_import | The receiver's type was found in an imported file |
0.85 | import_merged | It is in one of the imported files. Which one is unattributed |
0.75 | receiver_global | The (class, method) pair exists somewhere in the repo |
0.50 | global_unique | The name is unique repo-wide. A guess, and stored as one |
Those are nine of the 29. Because every origin has exactly one confidence, the origin distribution and the confidence histogram are two views of the same data, which is what makes the stamping checkable rather than decorative.
At the bottom of the ladder repowise declines rather than guesses. That costs recall, and it is the right way round: a missing arrow looks like missing information, a wrong arrow looks like an answer.
Three things follow in practice. An agent tracing an execution flow can decline anything below a threshold and know what it declined. A reviewer reading a blast radius can tell "this definitely breaks" from "this shares a method name with something that breaks". And when the graph is wrong, the origin names which strategy was wrong, so it gets fixed once rather than patched per call site.
The MCP get_context tool filters
callers and callees to confidence >= 0.7 by default.
Typing the receiver
Twelve of the 29 origins exist to answer one question properly: what is
user in user.save()? Rather than matching a bare method name against
every save in the repo, repowise reads the receiver's declaration
and resolves the method on that type.
void handle(UserRepo repo) { // parameter declares the type
var draft = new Draft(); // constructor declares the type
repo.save(draft); // resolves to UserRepo.save, not Draft.save
this.cache.evict(draft.id); // field on the enclosing class
}Five receiver shapes are covered, each its own origin family so each can be measured on its own:
| Shape | Example |
|---|---|
| Locals and parameters | var repo = new UserRepo(), fun f(r: UserRepo) |
| Fields of the enclosing class | this.cache.evict(...) |
| Freshly constructed receivers | new Foo().bar() |
| The method's own receiver | Go's func (s *Server) handle() |
| Framework-retyped symbols | @shared_task def add makes add a Task, so add.s() is Task::s |
Shipped for Java, C#, Python, Go, Kotlin, and Swift. The typed origins share their untyped twin's confidence on purpose: the inferred type had to declare the method before any edge was emitted, so the evidence is no weaker. What differs is how the receiver was named, and naming that difference is the whole point of an origin.
Languages are added one at a time, each gated on a sampled precision audit. Several are deliberately absent because they failed that gate. An unsupported language falls back to the weaker origins and says so.
Seventeen edge types, because calls was doing too many jobs
An edge type is a claim. If one type carries several different claims, every consumer downstream has to guess which one it is looking at. There are 17. These are the ones whose boundaries matter most:
| Edge | Claims | Does not claim |
|---|---|---|
calls | The parser saw a call expression and resolved its callee | |
references | Something holds a handle to this function: a dispatch-table entry, a callback field, a registration macro | That it is ever invoked. Enough to make deleting it unsafe, not enough to call it a call |
dispatches_to | A base method points at an implementation that could answer for it | A proven override. No signature is compared |
framework_binds | A framework wires these two symbols together: a pytest fixture, a Spring injection | A call. Nothing here is source a parser could have seen |
type_use | A type is referenced in a constructor, method, delegate, or record parameter | An import. Weighted below one |
co_changes | These files keep changing in the same commit | Any code dependency at all |
The references distinction is not academic. A handler sitting in a
dispatch table is never called anywhere a parser can see, and counting
that as "no use" reported entire registration layers as safe to delete.
framework_binds is separated from calls for the same reason in
reverse. A fixture nobody calls and a collaborator nobody constructs are
both genuinely used, by the container. But an inferred wiring hop is not
source, and letting it render as a call would put it into an execution
flow as though someone had written it.
What the resolvers handle
- Import aliases (
import foo as bar) - Barrel re-exports (
export * from "./x") - Namespace imports (
import * as Foo) - Path-mapped imports: TypeScript
tsconfig.jsonpaths, C#.csproj, Gogo.modreplace directives, RustCargo.tomlworkspace members - Heritage: extends, implements, trait impls, derive macros, mixins, Swift extension conformance
- Framework wiring, through 22 detectors that connect routes to handlers, DI registrations to implementations, and ORM entities to their relationships, across Django, FastAPI, Flask, Spring, ASP.NET, Rails, Laravel, Next.js, Express, Axum, Gin, and more
Per-language detail: Language support.
Flows that say why they stopped
An execution flow walks the call graph from an entry point. Every walk ends, and a trace that simply stops reads identically whether execution really ends there or the walker ran out of things it could follow.
So a flow never simply ends. It terminates with one of six reasons:
| Termination | Meaning |
|---|---|
no_callees | No outgoing call edges recorded |
cycle | Every successor was already on this trace: recursion or mutual calls |
depth_limit | The hop budget ran out. Nothing is known beyond it |
confidence_filtered | Every successor sat below the confidence floor |
excluded_target | Every successor was a test, demo, or fixture node |
callees_truncated | Rows were cut before the walk saw them |
no_callees is deliberately not called a leaf. A symbol whose calls
were not resolved looks exactly like a function that genuinely calls
nothing, and asserting the second is a claim the graph cannot support.
When a confidence floor is what stopped the walk, the flow reports which
origins it declined, which is the part you can act on. The tool is
get_execution_flows.
What runs over the graph
The graph is data. These are the products of it:
- PageRank over the file tier ranks what everything depends on.
- Betweenness centrality finds the bridges whose removal splits the graph. Neither it nor PageRank is fed co-change edges, because "changes alongside many things" is not "many things depend on it".
- In and out degree: direct dependents and dependencies.
- Strongly connected components: circular import groups, which need their own documentation strategy because nothing in them can be explained before the others.
- Leiden community detection (Louvain as fallback) finds the modules the codebase actually has, which is frequently not the directory layout. Each community gets a cohesion score and a label.
- Blast radius: change a file, walk the dependency edges, get the set of things that can break. Confidence travels with it, so a speculative hop is visible as one.
- Dead code: reachability over the union view, not over
callsalone. A symbol reached only by a framework, a dispatch table, or a type reference is not dead, and each of those is a separate edge type for exactly this reason.
Details on the algorithms themselves are in graph-algorithms.md.
How good is it, and how we know
Two readings of the same question. Only one of them used an answer key we do not control.
Graded by a compiler
On Go the answer key is the Go team's own RTA call graph from
golang.org/x/tools, computed over the fully type-checked program. On
TypeScript it is the tsc checker's own resolution of every call site.
We wrote neither and can tune neither, and anyone with the toolchain can
regenerate both.
Seven cells, five repositories, five tools, 37,853 oracle edges. Of the call edges repowise emits, the share the compiler confirms runs 0.943 to 0.992 per cell.
That figure alone is not the claim, because precision on its own has a cheap way to win: draw one edge you are certain of and score 1.000. Two of the five tools do a version of exactly that. One scores 0.997 on one repository, the highest figure in the experiment, from a graph holding 17% of the calls in it. So the claim is the pair:
In all seven cells, no tool that recovers as much of the call graph as repowise does gets more of it right.
The weaker readings, so nobody has to infer them: most precise outright in one cell, tied in one more, and beaten in five by tools drawing much smaller graphs. A precision figure quoted without a recall figure beside it is a misuse of this data, including by us.
Two languages, and only two. C#, Java, Kotlin, and C++ each need a toolchain installed and a working build per repository, and nobody has done that here. Python, Ruby, and PHP can never have an oracle at all, because what a call resolves to can change at runtime. That is a fact about those languages rather than a gap in the harness.
Graded by hand
Nine languages, 30 call edges per language per tool, every row opened in its own file with its imports and enclosing scope, then the target declaration opened too. 229 of 270 correct for repowise against 154 of 270 for the comparison tool, intervals disjoint. Four of the nine cells separate; five are ties and are reported as ties.
Read our own number the other way round: roughly fifteen percent of repowise call edges are wrong, concentrated in Java, Rust, and C++. That is the figure to plan against.
The two readings agree. On Go the hand grade says 96.7% and the compiler says 97.6%, over roughly 1,600 edges rather than 30 rows. A person reading source and a type checker landing within about a point of each other is the strongest available evidence that the hand-graded half is accurate rather than self-serving.
The column we lose
Recall is the other half of the question, and repowise does not lead it.
Across the five Go cells recall runs 0.32 to 0.96 and repowise leads none of them. On cross-file coverage over 35 repositories, one competing tool separates from us on 15 and we separate on none. We do lead recall in both TypeScript cells, and we lead it over the two tools that beat us on precision in every cell, which is the same trade seen from the other side.
The oracle explains the trade rather than excusing it. The tool that recovers more of the true call graph also emits far more that is not in it: on the largest Go repository measured, more than a third of what it emits is a call the compiler says does not exist. Coverage rewards drawing edges and never asks whether they are real.
Where our own miss goes, decomposed on one cell rather than waved at. On one Go repository without tests we miss 3,846 of the oracle's 7,898 edges. 44% of that miss is dynamic dispatch alone, with a further 39% dispatch with a closure at one end; the buckets overlap, so neither is the whole gap. Interface dispatch is the ceiling and nobody in the comparison has cleared it: of 3,303 dispatch edges, repowise matches 12, the next tool 35, the best 81, at 6.5 distinct possible targets per call site. Matching that recall means emitting six edges where one is right.
The obvious cheap fix was priced and refused: giving Go func literals
a symbol would recover 50 static edges on that cell, not the 1,309 the
raw closure count suggests, because the rest need the dispatch ceiling
cleared first.
Honest ceilings
- Resolution quality varies by language. Statically typed languages with explicit declarations resolve best. Dynamically typed and heavily reflective code resolves worst, and falls back to the low-confidence origins rather than pretending. Per-language detail is on Language support.
- Receiver typing reads declarations with per-language patterns, not a full type checker. A declaration the patterns cannot see is a receiver that does not get typed, and the call falls back to a weaker origin.
dispatches_tocompares no signature. It matches by method name, so it names a possible dispatch target rather than a proven override.- A repo-wide unique name is still a guess.
global_uniqueat0.50is where that lives. It is kept because a labelled guess beats a missing edge for reachability, and it is labelled so nothing downstream mistakes it for a fact. - Method-level dead-code detection is not shipped, for any language. It was measured, precision failed, and shipping it would have meant confidently recommending deletions that were wrong.
- Roughly fifteen percent of call edges are wrong, and repowise leads no Go recall cell. Both are measured and both are above rather than buried here.
- The compiler-graded reading covers two languages. On the other seventeen the precision figure is the hand-graded one, at 30 rows per language: a smaller sample, and a method we ran ourselves.
- A competitor's coverage lead is priced on two languages too. The tool that beats us on cross-file coverage across 35 repositories has an oracle-anchored precision figure on Go and TypeScript alone. That its extra edges are mostly wrong is measured there and inferred elsewhere.
See it on a file
From the CLI, pull PageRank, betweenness, and degree for one file with
--include metrics, or the callers and callees the resolver linked with
--include callers:
repowise context src/auth.py --include metrics
repowise context src/auth.py --include callersFrom an agent:
get_context(targets=["src/auth.py"], include=["metrics", "callers"])In the dashboard, repowise serve gives you the graph, architecture,
coupling, blast-radius, knowledge-graph, and dead-code views.
Refreshing
repowise update rebuilds only the slice of the graph affected by
changed files. A typical commit affects 3 to 10 nodes and takes under 30
seconds.
See also
- Dead code: reachability over the union view, tiered by confidence.
- Change risk: diff-shape scoring, kept distinct from structural impact.
get_context,get_risk,get_execution_flows, andget_dead_code: the tools that read the graph.- Glossary: every derived metric, defined.