The problem
Give a coding agent a large unfamiliar repo and watch it work: grep, read a
file, grep again. It burns context rebuilding structure a parser could have
supplied in one call — and still misses the caller three modules away that its change just
broke.
The reflex is RAG: embed the code, retrieve “similar” chunks. But “who calls this function?” is not a similarity question. It has an exact answer, and that answer lives in the call graph.
Live numbers from this repo
Impact analysis, before the edit
The query that earns the whole project. One call names the dependent files and the tests to run.
$ cartograph blast src/cartograph/graph/store.py
Blast radius — file src/cartograph/graph/store.py
17 dependent file(s), 31 affected symbol(s), 7 test file(s).
Tests to run first
tests/test_cli.pytests/test_docs.pytests/test_incremental.pytests/test_mcp.pytests/test_resolver.pytests/test_traversal.pytests/test_views.py
Dependent files (by import distance)
tests/test_cli.py· d1 · testtests/test_incremental.py· d1 · testtests/test_resolver.py· d1 · testsrc/cartograph/graph/resolver.py· d1src/cartograph/indexer/pipeline.py· d1src/cartograph/service.py· d1tests/test_mcp.py· d2 · testtests/test_traversal.py· d2 · testtests/test_views.py· d2 · testscripts/bench.py· d2scripts/build_docs.py· d2scripts/smoke_mcp.py· d2src/cartograph/cli.py· d2src/cartograph/mcp_server/server.py· d2src/cartograph/views.py· d2tests/conftest.py· d2tests/test_docs.py· d3 · test
Affected symbols
cartograph.graph.resolver:resolve_imports· src/cartograph/graph/resolver.py:93 · 0.45 (name-only)cartograph.graph.resolver:resolve_references· src/cartograph/graph/resolver.py:121 · 0.45 (name-only)cartograph.indexer.pipeline:index_repo· src/cartograph/indexer/pipeline.py:42 · 0.45 (name-only)cartograph.service:Cartograph._one· src/cartograph/service.py:118 · 0.45 (name-only)cartograph.service:Cartograph.blast_radius· src/cartograph/service.py:200 · 0.45 (name-only)cartograph.service:Cartograph.candidates· src/cartograph/service.py:124 · 0.45 (name-only)cartograph.service:Cartograph.find_symbol· src/cartograph/service.py:105 · 0.45 (name-only)cartograph.service:Cartograph.load· src/cartograph/service.py:84 · 0.45 (name-only)cartograph.service:Cartograph.stats· src/cartograph/service.py:293 · 0.45 (name-only)cartograph.service:Cartograph.symbol_detail· src/cartograph/service.py:127 · 0.45 (name-only)cartograph.service:Cartograph.who_calls· src/cartograph/service.py:155 · 0.45 (name-only)scripts.bench:benchmark· scripts/bench.py:62 · 0.45 (name-only)tests.test_cli:test_opening_an_index_writes_nothing· tests/test_cli.py:201 · 0.45 (name-only)tests.test_cli:test_reading_while_indexing_does_not_lock· tests/test_cli.py:185 · 0.45 (name-only)tests.test_cli:test_schema_is_not_recreated_on_reopen· tests/test_cli.py:220 · 0.45 (name-only)tests.test_incremental:test_fts_rows_do_not_leak_after_reindex· tests/test_incremental.py:189 · 0.45 (name-only)tests.test_resolver:test_deleting_a_target_removes_its_edges· tests/test_resolver.py:334 · 0.45 (name-only)tests.test_traversal:test_find_symbol_exact_flag_excludes_partial_matches· tests/test_traversal.py:42 · 0.45 (name-only)cartograph.cli:search_cmd· src/cartograph/cli.py:175 · 0.40 (ambiguous)cartograph.mcp_server.server:_reset· src/cartograph/mcp_server/server.py:71 · 0.40 (ambiguous)cartograph.mcp_server.server:search_code· src/cartograph/mcp_server/server.py:131 · 0.40 (ambiguous)cartograph.service:Cartograph.related· src/cartograph/service.py:181 · 0.40 (ambiguous)tests.test_incremental:edge_names· tests/test_incremental.py:19 · 0.40 (ambiguous)tests.test_incremental:qualnames· tests/test_incremental.py:28 · 0.40 (ambiguous)tests.test_incremental:test_a_single_edit_still_reresolves_globally· tests/test_incremental.py:273 · 0.40 (ambiguous)
Recall-first view (min_confidence 0.3): treat as a review checklist, not a proof of impact.
Architecture, extracted not documented
Module graph, layering, cycles and hotspots — computed from imports and the call graph. Edge labels are import counts; PageRank picks out the symbols that carry the codebase.
flowchart LR m0["cartograph.indexer.extract"] -- 5 --> m1["cartograph.models"] m2["cartograph.mcp_server.server"] -- 5 --> m3["cartograph.service"] m4["cartograph.cli"] -- 4 --> m3["cartograph.service"] m5["cartograph.views"] -- 4 --> m3["cartograph.service"] m6["cartograph.graph.resolver"] -- 3 --> m7["cartograph.graph.store"] m7["cartograph.graph.store"] -- 3 --> m1["cartograph.models"] m3["cartograph.service"] -- 3 --> m8["cartograph.graph.algorithms"] m9["scripts.build_docs"] -- 3 --> m3["cartograph.service"] m4["cartograph.cli"] -- 2 --> m10["cartograph.indexer.pipeline"] m6["cartograph.graph.resolver"] -- 2 --> m11["cartograph.indexer.languages"] m0["cartograph.indexer.extract"] -- 2 --> m11["cartograph.indexer.languages"] m10["cartograph.indexer.pipeline"] -- 2 --> m7["cartograph.graph.store"] m10["cartograph.indexer.pipeline"] -- 2 --> m1["cartograph.models"] m3["cartograph.service"] -- 2 --> m7["cartograph.graph.store"] m3["cartograph.service"] -- 2 --> m1["cartograph.models"] m5["cartograph.views"] -- 2 --> m1["cartograph.models"] m4["cartograph.cli"] --> m12["cartograph"] m4["cartograph.cli"] --> m11["cartograph.indexer.languages"] %% showing 18 of 35 module edges by weight
Hotspots — highest PageRank
| Symbol | Location | Fan-in | Rank |
|---|---|---|---|
cartograph.views:TokenBudget.add | src/cartograph/views.py:41 | 92 | 0.0243 |
cartograph.indexer.pipeline:index_repo | src/cartograph/indexer/pipeline.py:42 | 46 | 0.0237 |
cartograph.models:Symbol | src/cartograph/models.py:84 | 2 | 0.0227 |
cartograph.graph.store:_to_symbol | src/cartograph/graph/store.py:659 | 9 | 0.0213 |
tests.test_extract:parse | tests/test_extract.py:14 | 17 | 0.0131 |
tests.test_mcp:call | tests/test_mcp.py:43 | 15 | 0.0129 |
cartograph.service:Cartograph.load | src/cartograph/service.py:84 | 10 | 0.0110 |
cartograph.indexer.languages:adapter_for_path | src/cartograph/indexer/languages.py:425 | 5 | 0.0092 |
cartograph.graph.store:GraphStore.close | src/cartograph/graph/store.py:124 | 12 | 0.0092 |
cartograph.service:Cartograph.close | src/cartograph/service.py:94 | 12 | 0.0092 |
Confidence is a first-class column
Without a type checker you cannot know that store.who_calls() means
GraphStore.who_calls. You can only rank hypotheses. So every edge records the rule
that produced it and a confidence, and callers choose their own operating point:
who_calls is precision-first (≥0.5), blast_radius is
recall-first (≥0.3).
| Rule | Confidence | Intuition | Edges here |
|---|---|---|---|
same-file | 0.95 | the definition is right there in scope | 440 |
import | 0.90 | the file explicitly imported this name | 184 |
receiver-type | 0.85 | Foo.bar() where Foo is a known container | 0 |
same-module | 0.75 | sibling file in the same package | 2 |
unique-global | 0.60 | one repo symbol has this name, bare call site | 5 |
name-only | 0.45 | one match, but on an untyped receiver | 139 |
ambiguous | 0.40* | N candidates, kept as N edges at 1/N each | 145 |
external | 0.40* | rooted at a third-party or stdlib import | 197 |
unresolved | 0.00 | genuinely unknown (dynamic, or a typed method) | 475 |
The name-only tier exists because of a real bug:
seen.add(...) on a builtin set was resolving to a repo class’s
add method purely because the name was unique — and surfacing as a confident
caller. A method name on a receiver you cannot type is not evidence, so it now sits below the
precision line.
The tool surface
Ten MCP tools, two resources and a prompt. Descriptions are written as routing guidance, because the description is the prompt the model uses to choose.
find_symbol
Where is X defined? Ranked by structural importance.
search_code
Full-text over names, signatures and docstrings (BM25).
get_symbol
One symbol: signature, doc, members, callers, callees, source.
who_calls
Reverse call tree — before you change a signature.
what_it_calls
Forward call tree — understand code without reading every file.
blast_radius
What a change could break, and which tests to run.
related_symbols
“What else should I read?” via personalized PageRank.
file_summary
What a file defines, imports, and who imports it.
architecture_overview
Modules, layering, import cycles, hotspots, entry points.
index_stats
Index health and the edge-resolution breakdown by rule.
Benchmarks
Real repositories, single process, M-series laptop. Cold = full index; warm = no-op reindex.
| Repo | Files | KLOC | Symbols | Edges | Cold | Warm | Internal resolution |
|---|---|---|---|---|---|---|---|
| django | 2,973 | 534 | 45,394 | 252,441 | 11.9s | 0.67s | 83.2% |
| gin (Go) | 98 | 24 | 1,610 | 9,179 | 0.32s | 0.03s | 88.1% |
| flask | 83 | 18 | 1,624 | 4,271 | 0.21s | 0.03s | 87.4% |
Warm reindex is fast because resolution is skipped only when both its inputs
are provably unchanged. On Django that took a no-op run from 7.5s to 0.67s with a
byte-identical graph. Reproduce with scripts/bench.py --clone.
Try it
uv tool install cartograph-mcp
cartograph index ~/code/my-repo
cartograph arch
cartograph blast src/auth/token.py
# wire it into an agent
claude mcp add cartograph -- cartograph serve ~/code/my-repo
Limitations
Stated plainly, because a code-intelligence tool that oversells its precision is worse than useless.
- No type inference.
self.conn.execute(...)cannot be resolved without knowingconn’s type. Those stay unresolved, and they are the bulk of what remains. - Dynamic dispatch is invisible.
getattr(obj, name)(), decorator registries and DI containers produce no edges. - No cross-language edges. A TypeScript frontend calling a Python endpoint is two disconnected subgraphs.