Dependency Graph
A codebase is not just a list of files - it's a directed graph of types that depend on
each other. The trouble is that for me personally there is no single tool representing that graph well.
I build it for myself, and polished it to be used by fellow engineers.
It's language-agnostic by design and renders in milliseconds, because of
how it's built.
The first problem: Parsing
There are several tools that allows to show connections between components, each with their pros and cons.
Here I used hybrid approach to take best of each:
- Static parsers (tree-sitter) read one file perfectly and instantly, but
know nothing about the rest of the project.
- Language servers (LSP) hold a project-wide semantic index - every caller,
every implementation - but answering a query is a round-trip, and asking for anything is slow.
The graph never scans your workspace on load. It reads exactly one file
synchronously, then asks the LSP for the heavy part. The result is memoized, so each time you shift focus - the missing part is fetched and cached.
Two consequences fall out of this approach:
- Progressive rendering. Results stream in stages. The centre and its
dependencies appear in milliseconds from tree-sitter; callers and siblings fill
in as the language server responds.
- It settles and stays settled. Every node hop is memoized.
- the intrinsic half (centre + dependencies) is keyed on the file's own content hash;
- the extrinsic half (callers + siblings) on a workspace epoch bumped on save.
A node expanded once is replayed from cache - no LSP - so a large graph stabilises after one pass and clicking
the same node always yields the same neighbourhood.
The second problem: Visualization
Visualizing a graph isn't difficult, but making sense of it is. There are many possibilities, but I needed a balance between functionality and usability. After many iterations and research, I settled on a structure I'm happy with.
Focus a class and the graph centres on it, with relationships flowing top-to-bottom:
[ CallerA ] [ CallerB ] ← who uses this class (LSP)
↘ ↙
[ Focused Class ] ← the centre (tree-sitter)
↙ ↘
[ Dependency ] [ Parent ] ← what it uses / inherits (tree-sitter + LSP)
The view is organised as rings of relevance around the focus:
| Ring |
What it is |
How it's drawn |
| Focused |
The class you selected - the centre everything is built around. |
Opaque, highlighted. |
| Active |
Its direct neighbours, one hop away: callers, dependencies, parents, siblings. |
Opaque. |
| Inactive |
Two hops out - the neighbours of your neighbours, for context. |
Dimmed. |
| Shadow |
Three hops out. Hidden until you hover the node they hang off. |
Fade in when you focus inactive nodes |
This gives you depth, but no clutter:
- the immediate neighbourhood is highlighted with connections shown
- the surrounding topology is faintly visible
- and the next layer is hover away
For the basis of the visual language was picked Sugiyama framework, with a few adjustments:
- Cycle removal - call graphs have cycles, but layering needs a DAG, so a DFS drops back edges (greedy cycle removal).
- Layer assignment - longest-path ranking: each class sits below every class that reaches it, so a four-deep chain occupies four rows and layout depth reflects true dependency depth. Layers are shifted relative to the selected node - callers above, dependencies below.
- Crossing minimization - the median heuristic reorders each row by the median position of its neighbours, sweeping until crossings settle.
- Coordinate assignment - pulls each node toward its neighbours' average, packs the active subgraph tight and centred, and fans faded context nodes out to the sides.
The full algorithm and its references are in docs/graph.md; the
vocabulary (focused / active / inactive / shadow, the Nd/Nc notation) is in
docs/terminology.md.
Why an engineer might find this useful
- Onboarding a new area. Checking out an unfamiliar class and read its blast
radius - who depends on it, what it's usages just by a glance on the graph.
- Scoping a change. Before you touch a class, the callers ring is your impact
analysis: everything above the focus is what might break.
- Tracing a path. Single-click to glide from node to node, following a
dependency chain or a caller chain across files while the layout keeps your place.
- Spotting structure smells. Long chains, dense caller fans, and unexpected
cycles are visible as shapes - the things that are hard to feel from text.
- Understanding a hierarchy. Inheritance edges and sibling implementations show
a type's place in its family at a glance.
Using it
| Action |
Result |
| Single-click a node |
Focus it - the camera glides over and the file opens in a preview tab, so you can keep exploring without losing your place. |
| Double-click a node |
Open the file for real (pinned tab, focus moves to the editor). |
| Hover a node |
Switch context to it - its connections light up while everything else dims, and its shadow ring fades in. |
Re-centring is animated and stable: pick another class and shared nodes stay put
while the view re-flows around the new focus.
Languages
The extension is language-agnostic through a pluggable provider registry. The graph
engine and layout doesn't change per language - a language only contributes how a file
is read. There are two kinds of contribution:
- Generic providers - a declarative spec (a tree-sitter query + a few options). For most languages that's enough. To add one - just another entry in
src/graph/lang/generic/specs.ts.
- Dedicated providers - hand-written for languages that want richer structure (nested-type qualification, member-level annotations). Java and Python use these.
Out of the box:
| Language |
Extensions |
Provider |
Notes |
| Java |
.java |
dedicated |
nested types, Spring / Jakarta / Lombok role rules |
| Python |
.py |
dedicated |
decorators, Pydantic role rules |
| TypeScript |
.ts .mts .cts |
generic |
classes, interfaces, enums, decorators |
| TSX |
.tsx |
generic |
same as TypeScript, JSX-aware grammar |
| JavaScript |
.js .jsx .mjs .cjs |
generic |
extends + new X() dependencies |
| Go |
.go |
generic |
structs → classes, interfaces; field-type edges |
| C# |
.cs |
generic |
classes, interfaces, enums, attributes, namespaces |
| C++ |
.cpp .cc .cxx .hpp .hh .hxx |
generic |
classes, structs, enums, base-class edges |
| C |
.c .h |
generic |
structs, enums, struct-typed field edges |
Adding a language
LangSpec with a tree-sitter query whose captures (@def.class, @name, @extends, @implements, @uses, @annotation, @import, @package) the shared provider turns into graph nodes and edges. The capture vocabulary is documented in src/graph/lang/generic/provider.ts. Framework-specific "sugar" (e.g. Spring stereotypes → service/controller roles) layers on as optional RoleRules, independent of parsing.
License
Apache-2.0