Skip to main content

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 2.42s          # 45,515 symbols, 418,925 edges

$ heimdall impact django/shortcuts.py:get_object_or_404 -d 2 -c 0.7
impact of django/shortcuts.py:get_object_or_404: 10 symbols, 8 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

Two and a half seconds, 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.4% 75.3%
class instantiation, same file / imported 99.5% / 98.8% not modelled 95.2% / 98.2%
constant read, same file / imported 100.0% / 100.0% not modelled not modelled
function passed as a value, same file / imported 97.8% / 96.9% 1.9% / 0.1% 13.8% / 5.3%
mean targets emitted per call 1.00–1.06 1.00 1.00
edges the referee can refute outright 0 of 204,713 0 of 23,736 202 of 30,882
live symbols wrongly shown as uncalled 1 (0.0%) 3,635 (52.3%) 2,381 (34.2%)
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 2.7 s 23.6 s 29.8 s
index on disk 78 MB 730 MB 151 MB
impact X from cold 58 ms 969 ms 837 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:

  1. discovergit ls-files when the repo is a git checkout (so .gitignore is honoured for free), a filtered walk otherwise.
  2. 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.
  3. resolve — imports are matched by longest dotted-suffix, per scope, so a function with its own from x import y is not resolved against the whole file. as renames are followed home, but only when the import landed inside the repo, so from asyncio import run as arun cannot become a call to your own run. Calls are matched by name and scored: same file 0.95, imported file 0.80, globally unique 0.70, ambiguous 0.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.
  4. 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,277 of 350,104 call edges resolved by name alone — and every one of them is labelled confidence = 0.3.

A CALLS edge covers every way one symbol depends on another by name, not only invocation: reading a constant, handing a function over as a value (register(on_save), key=parse), naming a class as a receiver, and a decorator. All of those break the same way when the target changes, which is the question impact answers. Where they differ is the fallback: an unresolvable call is emitted as up to five 0.3 candidates, while an unresolvable reference is dropped entirely — a bare name with nothing behind it is almost always a local variable, and guessing five ways at a local variable is noise.

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, Constant. 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, Terraform. 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 four dicts in heimdall/parse.py — node types for definitions, for calls, for imports and for name bindings — 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.

Terraform

.tf and .hcl are read differently, because HCL has no definitions and no calls. A block is indexed under the address the rest of the configuration refers to it by — resource "aws_instance" "web" becomes aws_instance.web, variable "region" becomes var.region, and each locals attribute its own local.x — and every reference to one of those addresses becomes a CALLS edge. The scope is Terraform's own: a directory of .tf files is one module, and a module block's arguments resolve to the variable blocks in the directory its source points at, which is what makes what breaks if I rename this module input answerable.

heimdall context aws_instance.web    # what it reads, and who reads it
heimdall impact var.region           # everything downstream of one variable

Blocks are stored under the existing Class kind rather than a new one — the schema's six kinds are the same six for every language, and Class is already where Go's type_spec and C's struct live.

Scored against a regex oracle on three public repos — 432 HCL files, 5,104 blocks, 10,206 edges — one reference of 2,828 goes unresolved and none of the edges is wrong; see bench/README.md. Packer and Nomad .hcl parse with the same grammar and get symbols, but only Terraform's addressing rules are modelled.

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

heimdall_code-0.2.0.tar.gz (117.9 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

heimdall_code-0.2.0-py3-none-any.whl (43.1 kB view details)

Uploaded Python 3

File details

Details for the file heimdall_code-0.2.0.tar.gz.

File metadata

  • Download URL: heimdall_code-0.2.0.tar.gz
  • Upload date:
  • Size: 117.9 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

Hashes for heimdall_code-0.2.0.tar.gz
Algorithm Hash digest
SHA256 02b0610496cc89204c405782ee2e57b95a44109d9cb14f4b180e53f2d07b0246
MD5 c94adb2225b9080193f6a189df52e19b
BLAKE2b-256 8e4ea1c74c0c9ff61facd5b661cdcae521f246ab329eec168c088241ba353286

See more details on using hashes here.

File details

Details for the file heimdall_code-0.2.0-py3-none-any.whl.

File metadata

  • Download URL: heimdall_code-0.2.0-py3-none-any.whl
  • Upload date:
  • Size: 43.1 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

Hashes for heimdall_code-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 a8b1d452968f8f0ae41d2819a46368c4c5b06cb093dc2d69f8a638b8416410a2
MD5 96100e20500e06bd65b47ce10f28fec0
BLAKE2b-256 46d7a38c3f9893972b504e5375da8ab7d0736d42360a7d0973f12095da86e57e

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page