CodeGraph CodeGraph

Why Your AI Coding Agent Needs a Code Knowledge Graph

CodeGraph turns your codebase into a local knowledge graph for AI coding agents. Benchmark data, a runnable Python graph, and an adoption plan.

AI coding agents write code well and understand codebases poorly. That mismatch is the single biggest source of wasted tokens, slow tasks, and confident wrong answers in agentic coding today. Ask an agent to trace an authentication flow in a mid-sized service and it will grep, glob, and read its way through the tree, rebuilding by hand a map of symbols and call paths that the project already knows.

A code knowledge graph fixes the inventory problem. Instead of letting the agent rediscover structure on every question, you index the codebase once into a graph of symbols, call edges, imports, and routes, then hand the agent precise queries against that graph. CodeGraph, the MIT-licensed local indexer that has gathered roughly 68,700 stars on GitHub as of late August 2026, is currently the most complete implementation of this idea, and it works with Claude Code, Cursor, Codex CLI, opencode, Gemini CLI, Antigravity, Kiro, and GitHub Copilot.

This article does four things. First, it explains what CodeGraph is and how the machinery works, with current facts and numbers. Second, it walks you through building a miniature code knowledge graph in about 250 lines of pure Python, so you understand what these tools actually do rather than treating them as magic. Third, it reads the 2026 benchmark data the way a reviewer should, including the trade-off the headline numbers do not mention. Fourth, it gives you an adoption plan you can defend in a design review.

Table of Contents

The bottleneck is discovery, not generation

Watch a coding agent work on an unfamiliar codebase and count where the effort goes. Before it writes a line, it has to answer two questions: which files and symbols are relevant, and what breaks if I touch them. Without structural knowledge, it approximates the answer with text search and file reads. That works, eventually, but it has three failure modes that matter in production:

  1. It is repetitive. The same discovery runs again on every question and every session. Nothing is retained except what survives in the context window.
  2. It is noisy. Dynamic dispatch, re-exports, framework routing, and cross-language boundaries make text search actively misleading. Grepping for a handler name finds string matches, not callers.
  3. It burns the budget you pay for. Discovery tool calls are not free. They consume tokens, latency, and context window space that the actual task needs.

The pattern is old. Databases did not speed up queries by making scanners faster; they did it by indexing once and querying many times. A code knowledge graph applies the same move to source code: pay a one-time indexing cost, then answer structural questions with lookups instead of crawls.

Without a graph (per question):
  agent -> grep -> glob -> read file -> read file -> ... -> reconstruct call path -> answer
With a graph (per question):
  agent -> one graph query -> symbols + edges + call path -> answer

What CodeGraph is

CodeGraph is a local-first code intelligence tool. It parses a repository into a SQLite knowledge graph of files, symbols, call edges, imports, routes, and references, then exposes that graph to AI coding agents through an MCP server. No data leaves your machine, no API keys are involved, and the index lives in a .codegraph/ directory inside each project.

The facts as of late August 2026:

  • License and traction: MIT licensed, about 68,700 stars and 4,400 forks, with 905 commits of active development behind it.
  • Language coverage: 33 languages including TypeScript, JavaScript, Python, Go, Rust, Java, C#, PHP, Ruby, C, C++, Swift, Kotlin, Scala, Dart, Lua, R, Solidity, and Terraform. Twenty of those parse in a native Rust kernel; the rest use a tree-sitter path. Graphs from both engines are verified byte-for-byte identical.
  • Framework awareness: routing declarations across 17 web frameworks (Django, Flask, FastAPI, Express, NestJS, Laravel, Rails, Spring, Play, Gin, Axum, ASP.NET, Vapor, React Router, SvelteKit, Vue/Nuxt, Astro) become route nodes linked to their handlers, so asking for a handler’s callers surfaces the URL that binds it.
  • Cross-language bridging: Swift to Objective-C bridging, React Native legacy bridge, TurboModules, Fabric view components, and Expo Modules are stitched into single flows instead of stopping at language boundaries.
  • Scale: the Linux kernel, roughly 70,000 files with 2 million symbols and 6.4 million relationships, indexes in under 12 minutes on a 2-core, 6 GB VPS. The Swift compiler sources, about 27,000 files, fresh-index in around 100 seconds on a workstation.
  • Freshness: a native file watcher (FSEvents, inotify, or ReadDirectoryChangesW) auto-syncs on every change with a 2 second debounce by default. Responses that would reference a still-pending file get a staleness banner telling the agent to read that file directly, and a connect-time reconciliation absorbs changes made while no agent was running.

Installation is three commands, and the ordering matters:

# Step 1: install the CLI (no Node.js required for the bundle install)
curl -fsSL https://raw.githubusercontent.com/colbymchenry/codegraph/main/install.sh | sh
# If you already use Node, either of these works instead:
# npm install -g @colbymchenry/codegraph
# npx @colbymchenry/codegraph
# Step 2: wire the MCP server into your agents (new terminal)
codegraph install
# Step 3: build the graph for a specific project
cd your-project
codegraph init

Installing the CLI does not touch your agent configuration; codegraph install does. Initializing a project does not happen until codegraph init runs inside it. Those boundaries are good operational hygiene: you control exactly which agents get the tool and which projects get indexed. codegraph status reports index health, codegraph upgrade updates in place, and codegraph uninstall reverses everything it configured.

How a code knowledge graph works

Strip away the Rust kernel and the MCP transport, and every code knowledge graph is three components: an extraction pass, a resolution pass, and a query layer.

source files
    │
    ▼  extraction pass (per file: parse once, collect symbols + raw call sites)
symbols: module, class, function, method   each with qualified name, file, line
    │
    ▼  resolution pass (global: bind each call site to a known symbol)
edges: calls, contains, imports
    │
    ▼  query layer
callers_of(x) · callees_of(x) · impact_radius(x) · search(text)

The extraction pass is a parser walk. For each file, you record every definition (module, class, function, method) and every call site as raw text plus the enclosing scope. This pass is cheap and purely local.

The resolution pass is where the intelligence lives. A raw call site like charge(total, card) does not say which charge it means. You resolve it against the global symbol table using ranking rules: exact qualified match first, then same module, then same class, then shortest name. This is also the pass that catches what grep misses, because a resolved edge is a semantic fact (“reports.run_report calls payments.charge“) rather than a string coincidence.

The query layer is boring on purpose. Callers, callees, and impact radius become dictionary lookups. The blast radius of a change is a breadth-first walk up the call graph. Search is a substring scan, which CodeGraph backs with SQLite FTS5 at production scale.

That is the whole trick. CodeGraph’s value is not a smarter model; it is a precomputed answer to the question the model was going to spend forty tool calls deriving.

Now build one. Everything below runs on a fresh machine with Python 3.11 or newer and no third-party dependencies.

Prerequisites and Setup

You need Python 3.11+ (tested on 3.12) and pytest for the test run. Create the project structure:

mkdir -p mini-codegraph/src
cd mini-codegraph
touch src/__init__.py
# optional but recommended: isolated environment
python3 -m venv .venv
source .venv/bin/activate
python -m pip install pytest

You will create four files:

  • src/graph.py extracts symbols and edges from Python sources.
  • src/impact.py computes the blast radius of a change.
  • src/main.py indexes the project itself and runs three queries.
  • test_graph.py and test_main.py verify all of it.

No other files, no configuration, no dependencies beyond the standard library.

Build the extraction pass

Create src/graph.py with the data model and the per-file collector. The Symbol and Edge dataclasses are the graph’s nodes and edges; _FileCollector is an AST visitor that records every definition and every call site it encounters:

"""A miniature code knowledge graph for Python projects.
This module implements the core idea behind tools like CodeGraph: parse each
source file once, extract every symbol and the relationships between them,
and then answer structural questions without re-reading files. The graph is
the index; queries are graph lookups.
"""
from __future__ import annotations
import ast
from collections import defaultdict
from dataclasses import dataclass
from pathlib import Path
MODULE = "module"
CLASS = "class"
FUNCTION = "function"
METHOD = "method"
CONTAINS = "contains"
CALLS = "calls"
IMPORTS = "imports"
_CALLABLE_KINDS = (FUNCTION, METHOD, CLASS)
@dataclass(frozen=True)
class Symbol:
    """A node in the graph: a module, class, function, or method."""
    qualified_name: str
    kind: str
    file: str
    line: int
    @property
    def name(self) -> str:
        """The unqualified name, e.g. ``charge`` for ``payments.charge``."""
        return self.qualified_name.rsplit(".", 1)[-1]
@dataclass(frozen=True)
class Edge:
    """A directed relationship between two symbols."""
    source: str
    target: str
    kind: str
def _module_prefix(name: str, module_names: set[str]) -> str:
    """Return the longest known module name that prefixes ``name``."""
    parts = name.split(".")
    for size in range(len(parts), 0, -1):
        candidate = ".".join(parts[:size])
        if candidate in module_names:
            return candidate
    return ""
def _class_prefix(qualified_name: str) -> str:
    """Return the enclosing class of a method, or an empty string."""
    if qualified_name.count(".") < 2:
        return ""
    return qualified_name.rsplit(".", 1)[0]
class _FileCollector(ast.NodeVisitor):
    """Walk one AST and collect symbols, call sites, and imports."""
    def __init__(self, module_name: str, file: str) -> None:
        self.module_name = module_name
        self.file = file
        self.symbols: list[Symbol] = []
        # Each entry is (caller qualified name, raw callee expression text).
        self.call_sites: list[tuple[str, str]] = []
        self.imported_modules: set[str] = set()
        # Stack of (scope kind, name) pairs used to build qualified names.
        self._scopes: list[tuple[str, str]] = []
    def visit_ClassDef(self, node: ast.ClassDef) -> None:
        self.symbols.append(
            Symbol(self._qualified(node.name), CLASS, self.file, node.lineno)
        )
        self._scopes.append(("class", node.name))
        self.generic_visit(node)
        self._scopes.pop()
    def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
        self._visit_function(node)
    def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
        self._visit_function(node)
    def _visit_function(
        self, node: ast.FunctionDef | ast.AsyncFunctionDef
    ) -> None:
        kind = METHOD if self._current_class() is not None else FUNCTION
        self.symbols.append(
            Symbol(self._qualified(node.name), kind, self.file, node.lineno)
        )
        self._scopes.append(("func", node.name))
        self.generic_visit(node)
        self._scopes.pop()
    def visit_Call(self, node: ast.Call) -> None:
        caller = self._current_function() or self.module_name
        if isinstance(node.func, ast.Name):
            text = node.func.id
        else:
            text = ast.unparse(node.func)
        self.call_sites.append((caller, text))
        self.generic_visit(node)
    def visit_Import(self, node: ast.Import) -> None:
        for alias in node.names:
            self.imported_modules.add(alias.name)
    def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
        if node.module:
            self.imported_modules.add(node.module)
    def _qualified(self, name: str) -> str:
        parts = [part for _, part in self._scopes]
        return ".".join([self.module_name, *parts, name])
    def _current_class(self) -> str | None:
        for kind, name in reversed(self._scopes):
            if kind == "class":
                return name
        return None
    def _current_function(self) -> str | None:
        for index in range(len(self._scopes) - 1, -1, -1):
            if self._scopes[index][0] == "func":
                parts = [self.module_name]
                parts.extend(part for _, part in self._scopes[:index])
                parts.append(self._scopes[index][1])
                return ".".join(parts)
        return None

Three details worth noticing. First, the scope stack (_scopes) is what turns a bare refund into payments.OrderService.refund; qualified names are the join keys of the whole system. Second, visit_Call records the call expression as raw text (ast.unparse renders attribute calls like service.refund faithfully), and defers meaning to the resolution pass. Third, module-level calls are attributed to the module itself, which is correct: top-level code can call things too.

Resolve calls across files

Append the rest of src/graph.py: the in-memory query layer, and build_graph, which runs the two passes and resolves every call site:

class CodeGraph:
    """An in-memory query layer over extracted symbols and edges."""
    def __init__(self, symbols: list[Symbol], edges: set[Edge]) -> None:
        self.symbols: dict[str, Symbol] = {
            symbol.qualified_name: symbol for symbol in symbols
        }
        self.edges: set[Edge] = edges
        self._callees: dict[str, set[str]] = defaultdict(set)
        self._callers: dict[str, set[str]] = defaultdict(set)
        for edge in edges:
            if edge.kind == CALLS:
                self._callees[edge.source].add(edge.target)
                self._callers[edge.target].add(edge.source)
    def callees_of(self, qualified_name: str) -> list[Symbol]:
        """Symbols that ``qualified_name`` calls directly."""
        return self._lookup(self._callees.get(qualified_name, set()))
    def callers_of(self, qualified_name: str) -> list[Symbol]:
        """Symbols that call ``qualified_name`` directly."""
        return self._lookup(self._callers.get(qualified_name, set()))
    def search(self, text: str) -> list[Symbol]:
        """Case-insensitive substring search over qualified names."""
        needle = text.lower()
        hits = [
            symbol
            for symbol in self.symbols.values()
            if needle in symbol.qualified_name.lower()
        ]
        return sorted(hits, key=lambda symbol: symbol.qualified_name)
    def _lookup(self, names: set[str]) -> list[Symbol]:
        found = [self.symbols[name] for name in names if name in self.symbols]
        return sorted(found, key=lambda symbol: (symbol.file, symbol.line))
def _module_name(root: Path, file: Path) -> str:
    """Derive a dotted module name from a file path, e.g. ``src.graph``."""
    rel = file.relative_to(root).with_suffix("")
    if rel.name == "__init__":
        rel = rel.parent
    return ".".join(rel.parts)
def build_graph(root: Path) -> CodeGraph:
    """Parse every Python file under ``root`` into a CodeGraph.
    Two passes run over the tree. The first pass collects symbols and raw
    call sites per file. The second pass resolves each call site to a known
    symbol, which is exactly where cross-file intelligence lives.
    """
    root = root.resolve()
    symbols: list[Symbol] = []
    edges: set[Edge] = set()
    collectors: dict[str, _FileCollector] = {}
    symbol_module: dict[str, str] = {}
    symbol_class: dict[str, str] = {}
    for file in sorted(root.rglob("*.py")):
        rel_parts = file.relative_to(root).parts
        if any(part.startswith(".") for part in rel_parts):
            continue
        module_name = _module_name(root, file)
        rel_file = file.relative_to(root).as_posix()
        try:
            tree = ast.parse(file.read_text(encoding="utf-8"), filename=str(file))
        except SyntaxError:
            continue  # skip files that do not parse, like real indexers do
        collector = _FileCollector(module_name, rel_file)
        collector.visit(tree)
        symbols.append(Symbol(module_name, MODULE, rel_file, 1))
        for symbol in collector.symbols:
            symbol_module[symbol.qualified_name] = module_name
            if symbol.kind == METHOD:
                symbol_class[symbol.qualified_name] = symbol.qualified_name.rsplit(
                    ".", 1
                )[0]
            else:
                symbol_class[symbol.qualified_name] = ""
        symbols.extend(collector.symbols)
        collectors[module_name] = collector
    module_names = {
        symbol.qualified_name for symbol in symbols if symbol.kind == MODULE
    }
    for symbol in symbols:
        if symbol.kind != MODULE:
            edges.add(
                Edge(_module_prefix(symbol.qualified_name, module_names),
                     symbol.qualified_name, CONTAINS)
            )
    for module_name, collector in collectors.items():
        for imported in collector.imported_modules:
            if imported in module_names:
                edges.add(Edge(module_name, imported, IMPORTS))
    by_name: dict[str, list[Symbol]] = defaultdict(list)
    for symbol in symbols:
        if symbol.kind in _CALLABLE_KINDS:
            by_name[symbol.name].append(symbol)
    def resolve(caller: str, text: str) -> str | None:
        """Pick the best symbol for a raw call expression, or None."""
        candidates = by_name.get(text.rsplit(".", 1)[-1])
        if not candidates:
            return None
        caller_module = _module_prefix(caller, module_names)
        caller_class = _class_prefix(caller)
        def rank(symbol: Symbol) -> tuple[int, int, int, int, str]:
            exact = 0 if (
                text == symbol.qualified_name
                or symbol.qualified_name.endswith("." + text)
            ) else 1
            same_module = 0 if symbol_module[symbol.qualified_name] == caller_module else 1
            same_class = (
                0
                if caller_class and symbol_class[symbol.qualified_name] == caller_class
                else 1
            )
            return (exact, same_module, same_class, len(symbol.qualified_name),
                    symbol.qualified_name)
        best = min(candidates, key=rank)
        return best.qualified_name
    for collector in collectors.values():
        for caller, text in collector.call_sites:
            target = resolve(caller, text)
            if target is not None and target != caller:
                edges.add(Edge(caller, target, CALLS))
    return CodeGraph(symbols, edges)

The rank function inside resolve is the entire resolution policy, in priority order: exact or suffix qualified match, same module, same class, then shortest qualified name as a deterministic tiebreaker. A call to charge() from reports.py that imported it from payments resolves to payments.charge. A call to self.validate_card inside OrderService.refund resolves to another method of the same class. Builtin calls like len and print find no candidates and are dropped, which is exactly the filtering you want.

Is this heuristic as rigorous as CodeGraph’s Rust resolver with its per-language semantics, cross-language bridges, and byte-for-byte verification? No. It resolves the overwhelming majority of calls in ordinary Python, it is deterministic, and it is 250 lines you fully own. For production use, take the real tool; for understanding, this is the honest core of it.

Add the query layer

Create src/impact.py. This is the query grep cannot answer: walking up the call graph to find everything a change could reach:

"""Impact analysis: what breaks when a symbol changes.
This is the query that grep cannot answer. Walking up the call graph from
a symbol produces the blast radius of a change: every direct caller, and
transitively every caller of those callers.
"""
from __future__ import annotations
from collections import deque
from src.graph import CodeGraph, Symbol
def impact_radius(
    graph: CodeGraph, qualified_name: str, max_depth: int = 4
) -> list[tuple[Symbol, int]]:
    """Breadth-first walk up the call graph from ``qualified_name``.
    Returns every caller, and transitively every caller of those callers,
    as ``(symbol, depth)`` pairs sorted by depth then qualified name.
    """
    depths = {qualified_name: 0}
    results: list[tuple[Symbol, int]] = []
    queue: deque[str] = deque([qualified_name])
    while queue:
        current = queue.popleft()
        depth = depths[current]
        if depth >= max_depth:
            continue
        for caller in graph.callers_of(current):
            if caller.qualified_name in depths:
                continue
            depths[caller.qualified_name] = depth + 1
            results.append((caller, depth + 1))
            queue.append(caller.qualified_name)
    return sorted(results, key=lambda item: (item[1], item[0].qualified_name))

The max_depth cap matters more than it looks. On a real codebase the transitive callers of a widely used utility can reach into the thousands, and an agent does not need all of them; it needs the first two or three hops to decide what to test. Production tools expose the same knob.

Write the demo entry point

Create src/main.py. It indexes the project’s own source tree, which makes the demo self-referential and deterministic: the graph analyzes the code that builds it:

"""Index this project's own source tree and run three graph queries.
Running this file is the smallest end-to-end demo of the article's idea:
one pass builds the index, then callers, callees, blast radius, and search
all become dictionary lookups instead of file crawls.
"""
from __future__ import annotations
from pathlib import Path
from src.graph import build_graph
from src.impact import impact_radius
def main() -> None:
    project_root = Path(__file__).resolve().parent.parent
    graph = build_graph(project_root)
    files = {symbol.file for symbol in graph.symbols.values()}
    call_edges = [edge for edge in graph.edges if edge.kind == "calls"]
    print(
        f"Indexed {len(files)} files, {len(graph.symbols)} symbols, "
        f"{len(graph.edges)} edges ({len(call_edges)} call edges)"
    )
    target = "src.graph.build_graph"
    print(f"\nCallers of {target}:")
    for symbol in graph.callers_of(target):
        print(f"  {symbol.qualified_name} ({symbol.file}:{symbol.line})")
    print(f"\nCallees of {target}:")
    for symbol in graph.callees_of(target):
        print(f"  {symbol.qualified_name} ({symbol.file}:{symbol.line})")
    print(f"\nImpact radius of {target} (what a change could reach):")
    for symbol, depth in impact_radius(graph, target):
        print(f"  {'  ' * (depth - 1)}{symbol.qualified_name} (depth {depth})")
    print("\nSearch results for 'impact':")
    for symbol in graph.search("impact"):
        print(f"  {symbol.qualified_name} [{symbol.kind}] ({symbol.file}:{symbol.line})")
if __name__ == "__main__":
    main()

Run and Test

Run the demo from the mini-codegraph directory:

python -m src.main

Expected output:

Indexed 6 files, 45 symbols, 89 edges (44 call edges)
Callers of src.graph.build_graph:
  src.main.main (src/main.py:16)
  test_graph.make_project (test_graph.py:35)
  test_graph.test_indexes_its_own_source_tree (test_graph.py:108)
Callees of src.graph.build_graph:
  src.graph.Symbol (src/graph.py:29)
  src.graph.Edge (src/graph.py:44)
  src.graph._module_prefix (src/graph.py:52)
  src.graph._FileCollector (src/graph.py:69)
  src.graph.CodeGraph (src/graph.py:144)
  src.graph._module_name (src/graph.py:182)
  src.graph.build_graph.resolve (src/graph.py:248)
Impact radius of src.graph.build_graph (what a change could reach):
  src.main.main (depth 1)
  test_graph.make_project (depth 1)
  test_graph.test_indexes_its_own_source_tree (depth 1)
    src.main (depth 2)
    test_graph.test_call_edges_are_directed (depth 2)
    test_graph.test_extracts_symbols_with_kinds (depth 2)
    test_graph.test_impact_radius_respects_max_depth (depth 2)
    test_graph.test_impact_radius_walks_callers (depth 2)
    test_graph.test_records_import_edges (depth 2)
    test_graph.test_resolves_cross_module_calls (depth 2)
    test_graph.test_search_matches_substrings (depth 2)
    test_main.test_main_runs_and_prints_summary (depth 2)
Search results for 'impact':
  src.impact [module] (src/impact.py:1)
  src.impact.impact_radius [function] (src/impact.py:15)
  test_graph.test_impact_radius_respects_max_depth [function] (test_graph.py:85)
  test_graph.test_impact_radius_walks_callers [function] (test_graph.py:74)

Read that output as a review artifact. If build_graph changes, the impact radius says exactly which entry points and tests to check, including the module-level main() call in the __main__ guard. That is the sentence a senior engineer types into a pull request description, produced by a graph walk instead of memory.

Now the tests. Create test_graph.py in the project root. It builds a two-file sample project in a temporary directory and asserts on the graph it produces:

"""Tests for the miniature code knowledge graph."""
from __future__ import annotations
from pathlib import Path
from src.graph import CALLS, IMPORTS, CodeGraph, build_graph
from src.impact import impact_radius
PAYMENTS = '''\
def validate_card(number: str) -> bool:
    return len(number) == 16
def charge(amount: int, card: str) -> str:
    if not validate_card(card):
        raise ValueError("invalid card")
    return f"charged {amount}"
class OrderService:
    def refund(self, amount: int, card: str) -> str:
        return charge(-amount, card)
'''
REPORTS = '''\
from payments import charge
def run_report(total: int, card: str) -> str:
    return charge(total, card)
'''
def make_project(root: Path) -> CodeGraph:
    """Create a two-file sample project and index it."""
    (root / "payments.py").write_text(PAYMENTS, encoding="utf-8")
    (root / "reports.py").write_text(REPORTS, encoding="utf-8")
    return build_graph(root)
def test_extracts_symbols_with_kinds(tmp_path: Path) -> None:
    graph = make_project(tmp_path)
    assert graph.symbols["payments"].kind == "module"
    assert graph.symbols["payments.charge"].kind == "function"
    assert graph.symbols["payments.OrderService"].kind == "class"
    assert graph.symbols["payments.OrderService.refund"].kind == "method"
    assert graph.symbols["reports.run_report"].kind == "function"
def test_resolves_cross_module_calls(tmp_path: Path) -> None:
    graph = make_project(tmp_path)
    callers = {s.qualified_name for s in graph.callers_of("payments.charge")}
    assert callers == {"payments.OrderService.refund", "reports.run_report"}
    callees = {s.qualified_name for s in graph.callees_of("payments.charge")}
    assert callees == {"payments.validate_card"}
def test_records_import_edges(tmp_path: Path) -> None:
    graph = make_project(tmp_path)
    assert any(
        (edge.source, edge.target, edge.kind) == ("reports", "payments", IMPORTS)
        for edge in graph.edges
    )
def test_search_matches_substrings(tmp_path: Path) -> None:
    graph = make_project(tmp_path)
    hits = {s.qualified_name for s in graph.search("charge")}
    assert "payments.charge" in hits
    assert "payments.OrderService.refund" not in hits
def test_impact_radius_walks_callers(tmp_path: Path) -> None:
    graph = make_project(tmp_path)
    radius = impact_radius(graph, "payments.validate_card")
    by_name = {symbol.qualified_name: depth for symbol, depth in radius}
    assert by_name == {
        "payments.charge": 1,
        "payments.OrderService.refund": 2,
        "reports.run_report": 2,
    }
def test_impact_radius_respects_max_depth(tmp_path: Path) -> None:
    graph = make_project(tmp_path)
    radius = impact_radius(graph, "payments.validate_card", max_depth=1)
    assert [symbol.qualified_name for symbol, _ in radius] == ["payments.charge"]
def test_call_edges_are_directed(tmp_path: Path) -> None:
    graph = make_project(tmp_path)
    forward = any(
        edge.source == "payments.charge"
        and edge.target == "payments.validate_card"
        and edge.kind == CALLS
        for edge in graph.edges
    )
    backward = any(
        edge.source == "payments.validate_card"
        and edge.target == "payments.charge"
        and edge.kind == CALLS
        for edge in graph.edges
    )
    assert forward and not backward
def test_indexes_its_own_source_tree() -> None:
    root = Path(__file__).resolve().parent
    graph = build_graph(root)
    assert "src.graph.build_graph" in graph.symbols
    assert "src.impact.impact_radius" in graph.symbols
    callers = {s.qualified_name for s in graph.callers_of("src.graph.build_graph")}
    assert "src.main.main" in callers

And test_main.py, which pins the demo’s observable behavior:

"""Tests for the demo entry point."""
from __future__ import annotations
import pytest
from src.main import main
def test_main_runs_and_prints_summary(capsys: pytest.CaptureFixture[str]) -> None:
    main()
    output = capsys.readouterr().out
    assert "Indexed" in output
    assert "src.graph.build_graph" in output
    assert "Impact radius" in output

Run the suite:

python -m pytest -v

Expected output:

============================= test session starts ==============================
platform linux -- Python 3.12.13, pytest-8.3.3, pluggy-1.6.0
collected 9 items
test_graph.py::test_extracts_symbols_with_kinds PASSED                [ 11%]
test_graph.py::test_resolves_cross_module_calls PASSED                [ 22%]
test_graph.py::test_records_import_edges PASSED                       [ 33%]
test_graph.py::test_search_matches_substrings PASSED                  [ 44%]
test_graph.py::test_impact_radius_walks_callers PASSED                [ 55%]
test_graph.py::test_impact_radius_respects_max_depth PASSED           [ 66%]
test_graph.py::test_call_edges_are_directed PASSED                    [ 77%]
test_graph.py::test_indexes_its_own_source_tree PASSED                [ 88%]
test_main.py::test_main_runs_and_prints_summary PASSED                [100%]
============================== 9 passed in 0.07s ===============================

The tests cover the behaviors an article reader would actually rely on: symbol kinds, cross-module call resolution, import edges, substring search, blast radius depth ordering, the max_depth cap, edge direction, and the demo’s output contract. If you extend the graph (say, inheritance edges or a SQLite backend), add a test per behavior and keep the suite green before wiring anything to an agent.

Read the 2026 benchmarks like an engineer

CodeGraph publishes a benchmark, re-measured on 2026-08-05, that compares Claude Code (headless, Claude Opus 4.8) answering one architecture question per repository, with and without the graph, at the median of four runs per arm. The headline across seven repos in seven languages: 88% fewer tool calls, 53% faster, 62% fewer tokens, 44% cheaper, and zero file reads on all seven repos in the with-graph arm.

The per-repo table is where a reviewer should live:

CodebaseLanguage and sizeTool calls with / withoutTime with / withoutTokensCost
VS CodeTypeScript, ~11k files2 / 2858s / 2m 10s77% fewer71% cheaper
ExcalidrawTypeScript, ~640 files2 / 4345s / 2m 42s84% fewer78% cheaper
DjangoPython, ~3k files3 / 1454s / 1m 23s41% fewer13% cheaper
TokioRust, ~790 files3 / 291m 3s / 2m 43s65% fewer64% cheaper
OkHttpJava, ~645 files1 / 633s / 58s54% fewer21% cheaper
GinGo, ~110 files1 / 728s / 46s52% fewerroughly even
AlamofireSwift, ~110 files4 / 3354s / 2m 22s59% fewer57% cheaper

Three engineering observations fall out of that table:

  1. Cost tracks discovery demand, not repo size. Savings run 57 to 78% where the file-reading arm needed 28 to 43 tool calls, but only 13% on Django and roughly even on Gin, where the control arm reached an answer in 14 and 7 calls. The graph saves you the discovery you would otherwise perform. A tidy codebase with an obvious layout gives the graph less to save.
  2. Tool call compression is the stable win. Every repo, every size, lands at 1 to 4 calls with the graph. That consistency is what makes the tool predictable enough to put in a pipeline: you are budgeting a handful of calls per question instead of a long tail.
  3. Zero file reads is the underrated column. In the with-graph arm the agent never opened a file on any of the seven repos. It answered from graph payloads. That is the difference between “the agent searched well” and “the agent did not need to search.”

One methodology note that raises my trust in the numbers: the harness blocks the codegraph CLI over Bash in both arms. On an earlier unblocked harness, the control agent found the CLI on PATH and reached CodeGraph through the shell in 26 of 28 runs, contaminating the comparison in both directions. The published re-measurement reports 0 of 28 contaminated runs. Most vendor benchmarks would not have disclosed that.

The trade-off the benchmarks do not headline

The aggregate numbers measure throughput: tokens processed, tools called, dollars spent to reach one answer. They do not measure what is still sitting in your context window afterward, and on that axis CodeGraph costs more, not less. Across the same seven repos in multi-turn sessions, graph responses left about 80% more retrieval context resident at session end than file-by-file exploration. On VS Code that was 67k tokens against 18k.

The mechanism is the same one that makes it fast. One dense, verbatim payload answers the question and then stays in the window, while a grep-and-read agent churns through many small results that get evicted. Fewer tokens processed and a larger persistent footprint are both true at once.

The practical consequences:

  • Budget for it on small context windows. If your team runs long agentic sessions in a tight window, dense graph payloads can crowd out working memory for the actual task.
  • Prefer it for precise, bounded questions. “Who calls charge and what breaks” gets a compact answer. “Explain this whole subsystem” will fetch a large payload either way.
  • Measure both axes yourself. Track tokens processed and end-of-session resident context on your own repos before you call the tool a net win for your workflow. The project publishes per-repo residual context measurements, which is a good sign, but your sessions are not their benchmark.

Where a graph beats RAG, and where it does not

A conventional code RAG system chunks files, embeds them, and retrieves semantically similar passages. That works well when the query is natural language about documentation or domain concepts. It works poorly for architecture, because architecture is relational. A function’s importance depends on its callers, its imports, the routes bound to it, and the tests that pin it. Semantic retrieval may tell an agent that two files both discuss billing; a call graph tells the agent which function actually reaches the payment provider. For refactoring and impact analysis, the second answer is the actionable one.

The two are complements, not competitors. Graph traversal answers structural questions exactly; embeddings are the right tool for “where do we discuss rate limit policy” style questions. If your agents’ failure mode is “cannot reconstruct the repository’s shape quickly enough,” the graph is the higher-leverage half of the pair.

A practical adoption plan

If I were rolling this onto a team tomorrow, it would go in three steps with exit criteria at each one.

Week one: read-only evidence. Install the CLI, wire it into one agent, index one moderately sized service. Run the tasks your team already runs: explain this request path, list the callers of this function, what does this config change touch. Record tool call counts, wall time, and answer accuracy against the same tasks without the graph. Exit criterion: you can name a number for your codebase, not a vendor’s number.

Week two: test planning. Before any modification of a shared symbol, have the agent produce the blast radius and the affected test suites as part of the change plan, before it gets write access. This uses the graph as an evidence layer and keeps every existing review gate intact. Exit criterion: pull requests start citing impact radius in their descriptions, and spot checks confirm the cited edges exist.

Ongoing: monitor the context axis. Watch end-of-session context occupancy on long sessions. If dense payloads crowd small windows, shorten sessions, or steer agents toward narrower queries, or both. Exit criterion: nobody can say “the graph made sessions faster but broke the long refactors” without data attached.

What I would not do: adopt it as a mandate across every repo on day one, or use it to justify removing review gates because “the agent knows the impact.” The graph is an input to engineering judgment, not a replacement for it.

Production considerations

  • Index freshness is a correctness concern. A stale graph is worse than no graph, because it lends false confidence to impact analysis. CodeGraph’s watcher syncs on change with a 2 second default debounce, marks responses that reference still-pending files with a staleness banner, and reconciles at MCP connect time. If you script against any index directly, sync before you trust it.
  • The index is local, and so is your blast radius. The .codegraph/ directory lives inside each project. Add it to .gitignore like any build artifact, and treat it as machine-local cache. Nothing leaves the machine, which keeps strict-repository security policies satisfied, but it also means CI runners need their own index step if you want graph-backed checks in the pipeline.
  • Resolution quality varies by language. Heuristic call resolution covers ordinary code well and degrades on heavy metaprogramming, reflection, or dynamic dispatch. The production tool ships cross-language bridges for the worst gaps (Swift to Objective-C, React Native bridges, framework routes), but treat impact radius as strong evidence, not proof, on code that leans dynamic.
  • Scale your expectations to the machine. The real tool sizes its worker pools from actual core counts and available RAM, which is why a 2-core VPS finishes the Linux kernel instead of dying at one percent. For the miniature version in this article, index time grows linearly with file count; fine for a service, not for a monorepo.
  • Cost is a per-question property. The benchmark spread (13% to 78% savings) says the graph pays for itself in proportion to how much discovery a question demands. Profiling your own question mix is the only honest forecast.

Troubleshooting

A few predictable failures and their fixes, for both the real tool and the miniature version:

Symptom: agent still greps instead of using the graph
Fix: the agent was wired after the session started. Restart the agent so it
     picks up the MCP server config, and confirm with `codegraph status`.
Symptom: "command not found: codegraph" right after install
Fix: the installer does not modify your current shell. Open a new terminal.
Symptom: impact radius looks incomplete or wrong
Fix: check that the files involved parse cleanly. The miniature graph skips
     files with syntax errors silently, and heavy metaprogramming defeats
     name-based resolution. Read the source before trusting the edge list.
Symptom: graph answers reference code you just changed
Fix: you are inside the debounce window or the watcher is disabled. Run
     `codegraph status` to see pending syncs, or `codegraph sync` if you
     disabled the daemon.
Symptom: tests fail after editing graph.py
Fix: the demo output and tests assert on the current file layout. Re-run
     `python -m pytest -v` after any change; the self-referential test
     (test_indexes_its_own_source_tree) will catch regressions in extraction.

Conclusion

AI coding agents stopped being limited by their ability to write code a while ago. What limits them now is how expensively they learn the codebase they are working in, and how reliably they reconstruct the structure their changes travel along. A code knowledge graph attacks both problems at once: it moves discovery from per-question cost to one-time index cost, and it turns questions grep cannot answer, like blast radius, into lookups.

The miniature graph in this article is deliberately small, but it is not a toy in the dismissive sense. It implements the same three-pass architecture the production tools use: extract, resolve, query. When the agent in your editor hands you a call path that includes a dispatch hop you did not know about, or a benchmark table claims 88% fewer tool calls, you now know exactly what machinery produces those claims and where its failure modes live (heuristic resolution, staleness windows, residual context).

Next steps:

  • Install CodeGraph on one real project with codegraph install and codegraph init, and measure your own before/after numbers for a week.
  • Extend the miniature graph with one feature that matters to you: inheritance edges, a SQLite backend with FTS5, or a simple MCP server that exposes callers_of and impact_radius to your agent.
  • Bring impact radius into your team’s change-plan template, and make “what does this touch” a question with a data source instead of a memory test.

Sources