# Dependency graph

A two-tier graph of files and symbols where every call edge is stamped with the strategy that resolved it, built with no model calls and no network.

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.

```mermaid
flowchart TD
  subgraph Files
    A[auth.py] -->|imports| B[db.py]
    A -->|imports| C[utils.py]
    D[routes.py] -->|imports| A
  end
  subgraph Symbols
    AS1[login fn] -.contains.-> A
    AS2[verify_token] -.contains.-> A
    BS1[get_user] -.contains.-> B
    AS1 -->|calls| AS2
    AS1 -->|calls| BS1
    DS1[POST /login] -.contains.-> D
    DS1 -->|calls| AS1
  end
```

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 39 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`](https://docs.repowise.dev/mcp/get-context) tool filters
callers and callees to confidence `>= 0.7` by default.

## Typing the receiver

Twelve of the 39 origins exist to answer one question properly: what is
`user` in `user.save()`? Instead of matching a bare method name against
every `save` in the repo, repowise reads the receiver's **declaration**
and resolves the method on that type.

```java
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, Swift and Dart**. 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.json` `paths`, C# `.csproj`,
  Go `go.mod` replace directives, Rust `Cargo.toml` workspace members
- npm/yarn/pnpm workspace packages, found at any depth, resolved before
  falling back to an `external:` node
- Heritage: extends, implements, trait impls, derive macros, mixins,
  Swift extension conformance
- Python imports by their full dotted module path, so `from pydantic
  import X` cannot land on a repo's own `utils/pydantic.py`
- Chained field receivers (`this.store.users.find()`,
  `self.store.users.find()`) resolve to the method they call
- Overload stubs (Python `@overload`, TypeScript overload signatures) are
  declarations, never mistaken for the implementation in a lookup
- 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](https://docs.repowise.dev/installation/languages).

## 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`](https://docs.repowise.dev/mcp/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", and
  both drop test-to-test edges so one test importing another cannot
  inflate either ranking.
- **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 `calls`
  alone. 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](https://github.com/repowise-dev/repowise/blob/main/docs/architecture/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:

> **Note**
>
> 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 (Java read at 40 rows,
widened after the first repository proved an outlier), every row opened in
its own file with its imports and enclosing scope, then the target
declaration opened too. **240 of 280 correct (85.7%)** for repowise against
164 of 280 (58.6%) 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 fourteen percent of
repowise call edges are wrong**, concentrated in Rust, C++, and Java.
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, at 6.5 distinct possible targets per call
site. Matching that recall means emitting six edges where one is right.

## 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](https://docs.repowise.dev/installation/languages).
- **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_to` compares 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_unique` at
  `0.50` is 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`:

```bash
repowise context src/auth.py --include metrics
repowise context src/auth.py --include callers
```

From an agent:

```python
get_context(targets=["src/auth.py"], include=["metrics", "callers"])
```

Callers that reach an implementation through its interface (a Spring service
called by its injected interface) are rolled up and marked `via: interface`.

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](https://docs.repowise.dev/intelligence/dead-code): reachability over the union
  view, tiered by confidence.
- [Change risk](https://docs.repowise.dev/intelligence/change-risk): diff-shape scoring,
  kept distinct from structural impact.
- [`get_context`](https://docs.repowise.dev/mcp/get-context), [`get_risk`](https://docs.repowise.dev/mcp/get-risk),
  [`get_execution_flows`](https://docs.repowise.dev/mcp/get-execution-flows), and
  [`get_dead_code`](https://docs.repowise.dev/mcp/get-dead-code): the tools that read the
  graph.
- [Glossary](https://docs.repowise.dev/reference/glossary): every derived metric, defined.
