A code knowledge graph that sees everything -- indexes any repository into a SQLite property graph and serves it to AI agents over MCP.
Project description
Heimdall
A code knowledge graph that sees everything.
Heimdall indexes any repository into a property graph — every symbol, call, import and inheritance edge — then serves it to coding agents over MCP so they can answer "what calls this?", "what breaks if I change it?" and "how does A reach B?" without grepping the whole tree.
Apache-2.0. No server, no cloud, no embedded graph engine. The graph is a SQLite file.
$ heimdall index ~/code/django
pass 1/4 discovering files
2974 source files
pass 2/4 parsing (24 workers)
pass 3/4 building graph
pass 4/4 building search index
done in 1.49s # 43,482 symbols, 353,101 edges
$ heimdall impact django/shortcuts.py:get_object_or_404 -d 2 -c 0.7
impact of django/shortcuts.py:get_object_or_404: 6 symbols, 4 files
depth 1 (5):
Function django/contrib/flatpages/views.py:flatpage (0.80)
django/contrib/flatpages/views.py:22
...
$ heimdall trace flatpage HttpResponse
path of length 2:
-> django/contrib/flatpages/views.py:flatpage
-> django/contrib/flatpages/views.py:render_flatpage
-> django/http/response.py:HttpResponse
A second and a half, cold, for the whole of Django on a laptop.
Why
GitNexus showed that agents work far better with a precomputed structural index than with raw search. Heimdall takes that idea and rebuilds it from scratch under Apache-2.0, in Python, on a much lazier stack:
| GitNexus 1.6.9 | Heimdall | |
|---|---|---|
| License | PolyForm Noncommercial | Apache-2.0 |
| Graph store | LadybugDB (native + WASM) | sqlite3 (stdlib) |
| Traversal | Cypher engine | recursive CTEs |
| Search | BM25 + embeddings + RRF | FTS5 BM25 (stdlib) |
| Runtime | Node.js / browser WASM | CPython |
| Dependencies | 33 | 2 (tree-sitter, mcp) |
| Ingestion | 15 phases | 4 passes |
GitNexus is the larger tool and does things Heimdall does not: a program dependence graph
and taint analysis, API route and DI extraction, cross-repository contract graphs, MRO
resolution, embeddings, a web UI. If you need those, use it — the honest comparison is in
bench/README.md, not in this table. The same goes for graphify, which
indexes docs, images and video into the same graph and has an LLM extraction pass on top;
only its AST code extraction is measured here, because that is the part an ast oracle
can adjudicate.
Heimdall's bet is narrower: that the structural core of that idea fits in
sqlite3 + tree-sitter, and that everything traversal and ranking needs is already in the
standard library. Measured on the same repository — Django,
2,927 Python files — graded by an independent CPython ast oracle, against GitNexus and
against graphify in its AST-only mode:
| Heimdall | GitNexus | graphify | |
|---|---|---|---|
self.m() bound correctly |
100.0% | 97.1% | 92.6% |
| same-file call bound correctly | 100.0% | 93.4% | 89.3% |
| imported call bound correctly | 99.7% | 71.5% | 75.4% |
| class instantiation, same file / imported | 99.5% / 98.8% | not modelled | 95.2% / 98.2% |
| mean targets emitted per call | 1.00–1.06 | 1.00 | 1.00 |
| edges the referee can refute outright | 0 of 157,036 | 0 of 23,736 | 202 of 30,882 |
| live symbols wrongly shown as uncalled | 1 (0.0%) | 1,373 (29.4%) | 205 (4.4%) |
| hand-written edge cases missed or fabricated | 1 of 13 | 4 of 13 | 3 of 13 |
who calls Model.check, precision |
94% | 0% | 95% |
| index from scratch | 1.7 s | 23.0 s | 28.8 s |
| index on disk | 48 MB | 642 MB | 151 MB |
impact X from cold |
56 ms | 949 ms | 856 ms |
Six of those Heimdall numbers only got there because the benchmark existed. It found that
imports were resolved file-wide rather than per-scope, that a Python pkg/main.py
registered as pkg only, that as renames were never followed, that a class name used as
a receiver was ignored, that recursion produced no edge, and — the moment the benchmark
moved to Django — that @decorator(...) was booked against the enclosing class instead of
the function it wraps, which alone cost 26% of imported-call recall. All fixed, all covered
by a test. See bench/README.md for the oracle, the 31 cases, and the
caveats — including the ones that do not flatter us.
Install
uv tool install heimdall-code # or: pip install heimdall-code
heimdall install # wire it into every agent on this machine
Or nothing at all: uvx heimdall-code index .. The distribution is heimdall-code
because an unrelated heimdall already sits on PyPI; the command is heimdall.
Python 3.12+. Two dependencies: tree-sitter-language-pack for parsing and mcp for
the server. Everything else is the standard library.
Use
heimdall index [PATH] # build .heimdall/graph.db (idempotent, re-run any time)
heimdall search "retry backoff" # BM25 ranked symbols
heimdall context MyClass.method # definition, callers, callees, members, supertypes
heimdall impact process_batch # blast radius, grouped by call depth
heimdall trace main save_to_s3 # shortest path between two symbols
heimdall stats # what is in the graph
heimdall sql "SELECT ..." # read-only SQL escape hatch
heimdall projects # every repo you have indexed
heimdall install [claude|codex] # register with an agent, as MCP or as a CLI skill
Every command takes --json for machine consumption and -p/--project NAME to query a
repository other than the current directory.
MCP
Indexing registers the repository in ~/.heimdall/registry.json, so one MCP server
serves all of them. heimdall install writes that server into whichever agents you name:
| writes | |
|---|---|
heimdall install |
both of the below, whichever are present |
heimdall install claude |
~/.claude.json |
heimdall install claude --project |
./.mcp.json, committed with the repo |
heimdall install codex |
~/.codex/config.toml |
heimdall install codex --project |
./.codex/config.toml — Codex reads it once the project is trusted |
heimdall install … --cli |
the skill instead of the server, so the agent drives the CLI: ~/.claude/skills/heimdall/, ~/.agents/skills/heimdall/ |
The command written is the copy of heimdall that ran the install, or
uvx --from heimdall-code heimdall mcp when that copy is a throwaway uvx environment
that uv would later collect. Your other settings are merged around it, and a config file
that does not parse is reported rather than replaced.
As a Claude Code plugin
/plugin marketplace add stPhoenix/heimdall
/plugin install heimdall
Server and skill, no Python install — the plugin's .mcp.json runs it through uvx.
Raw config for other clients
{
"mcpServers": {
"heimdall": { "command": "heimdall", "args": ["mcp"] }
}
}
| Tool | Answers |
|---|---|
overview |
What is this repo? Languages, hot files, entry points |
search |
Where is the thing called roughly this? |
context |
Definition, members, callers, callees, supertypes of one symbol |
impact |
What breaks if I change this? Grouped by call depth |
trace |
How does A reach B? |
read_symbol |
The source of one function, not the whole file |
sql |
Anything else — the schema is two tables |
projects |
Which repos are indexed |
Every tool takes an optional project argument, so an agent can reason across
repositories in one session. Results are compact JSON, and any truncation is reported
in the payload rather than silently applied.
How it works
Four passes, all precomputed at index time so a query is one SQL statement:
- discover —
git ls-fileswhen the repo is a git checkout (so.gitignoreis honoured for free), a filtered walk otherwise. - parse — tree-sitter, one process per core. Symbols, calls, imports and heritage
clauses come out of a data table of node types per language, not per-language code.
A decorator counts as a call by the function it wraps, so
@override_settings(...)puts the test in that helper's blast radius. - resolve — imports are matched by longest dotted-suffix, per scope, so a function
with its own
from x import yis not resolved against the whole file.asrenames are followed home, but only when the import landed inside the repo, sofrom asyncio import run as aruncannot become a call to your ownrun. Calls are matched by name and scored: same file0.95, imported file0.80, globally unique0.70, ambiguous0.30. Once any candidate has real evidence behind it the name-only matches are dropped rather than listed. Confidence is stored on the edge so callers can filter. - index — FTS5 over names, qualified names and signatures.
Receivers are used wherever they carry information: self.foo() resolves inside the
enclosing class, utils.foo() inside the file imported as utils, and Model.foo()
inside the class Model — including when several classes are called Model, because
the one that defines foo is the one that was meant. On Django that leaves 46,260 of
286,346 call edges resolved by name alone — and every one of them is labelled
confidence = 0.3.
Schema
node(id, kind, name, qname, path, line, end_line, lang, signature)
edge(src, dst, kind, confidence, line)
kind on nodes: Folder, File, Class, Interface, Function, Method.
kind on edges: CONTAINS, DEFINES, IMPORTS, CALLS, EXTENDS.
That is the whole data model — heimdall sql gives you direct access to it.
Languages
Python, JavaScript, TypeScript, TSX, Go, Rust, Java, Kotlin, C#, Ruby, PHP, C, C++, Swift, Scala, Lua, Dart, Bash. Every one of them is covered by a test that asserts it still yields symbols and call edges, so a grammar rename fails the build rather than silently degrading the graph.
Adding a language means adding rows to three dicts in heimdall/parse.py — node types
for definitions, for calls, and for imports — plus a snippet in the test.
One known gap: in Ruby, a receiver-less call without parentheses (foo) is
indistinguishable from a variable reference in the grammar, so only foo() and
x.foo produce call edges.
Limitations
Call resolution is name-based, not type-based. self.x() and module.x() resolve
exactly; a call on an inferred variable (client.get()) produces up to five
confidence = 0.3 edges rather than one certain edge. Filter them out with
--min-confidence 0.7; trace already does by default.
Inherited members are not followed when resolving self.x(), indexing is full-rebuild
rather than incremental, and there are no embeddings, no community detection and no
control-flow graph.
One gap has a test standing on it, so it is known rather than suspected: a local name that
shadows a module-level function (parse = json.loads inside a function that lives next to
a def parse) still resolves to the module-level one — GitNexus gets this wrong too, and
fixing it means real scope analysis.
"Who writes into this table" used to be the other one. A receiver whose name several
classes share — Django has 236 classes called Model — made the resolver give up and
fall back to matching check by name, which buried the 103 real callers of Model.check
in 442 rows. Ambiguity in the receiver is not ambiguity in the call, though: exactly one
of those 236 classes defines check. Keeping every candidate class and letting the member
decide answers it in 109 rows at 94% precision, with no schema change and no change in
index time. What is left is the variable receiver — field.check(...) in the same file as
Model.check — which is name ranking working as designed, and marked as such by its
confidence.
Community detection is the one of those with a number attached, and Django moved it. On GitNexus's own call graph, 15 lines of stdlib label propagation still beat the vendored Leiden outright — 0.801 Newman modularity against 0.613, with the directory tree at 0.479. On Heimdall's much denser graph it does not: label propagation collapses to 74 groups and scores 0.188, below Leiden's 0.257 and below the directory tree's 0.224. So clustering is worth having and the cheap algorithm is not uniformly enough. If it lands here it lands with that caveat measured, not assumed.
Contributing
Layout, how to add a language, and the release steps are in
CONTRIBUTING.md; what changed is in CHANGELOG.md.
A resolution bug is worth a fixture in tests/test_heimdall.py — every one found so far
has one.
License
Apache License 2.0 — see LICENSE. Independent implementation; GitNexus is credited as inspiration only, no code was taken from it.
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file heimdall_code-0.1.0.tar.gz.
File metadata
- Download URL: heimdall_code-0.1.0.tar.gz
- Upload date:
- Size: 104.6 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.12.1 {"installer":{"name":"uv","version":"0.12.1","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
2dfd9f419c07f937a093a83c508391d8e0ba107fd91ea5e368b39a65f1d2b659
|
|
| MD5 |
c069d4460bab192b834dee65441e2893
|
|
| BLAKE2b-256 |
c7e4bb80f095c451e4bd014ca726d37fccc7eeec2a537c1164aa0f86d0bbf93c
|
File details
Details for the file heimdall_code-0.1.0-py3-none-any.whl.
File metadata
- Download URL: heimdall_code-0.1.0-py3-none-any.whl
- Upload date:
- Size: 37.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.12.1 {"installer":{"name":"uv","version":"0.12.1","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
539bf84145d2298a5c1cc7c4b0a3c5f38322fb3896b8ef06311ea1b45f0832dd
|
|
| MD5 |
467bd29236b02dc78703c1d46e7677fe
|
|
| BLAKE2b-256 |
ad292cda1f3977a2ef50802ef10813b7810b7ab7bf2061e0a0c91bfd7a0860f9
|