How vitali87/code-graph-rag Resolves Function Calls Across the Knowledge Graph

Code‑graph‑rag resolves function calls by building a trie‑backed function registry that maps qualified names (QNs) to graph nodes, then using a multi‑stage resolver that performs exact lookups, simple‑name disambiguation, prefix‑and‑suffix matching, and return‑type inference to emit precise CALLS edges.

The vitali87/code-graph-rag project constructs a knowledge graph where every function, method, class, module, and package becomes a node. When the parser encounters a call expression, it must map that call to its target QN—a process that spans simple identifiers, fully qualified names, and complex factory patterns. This article examines how the resolution engine works, from trie construction to edge emission, using actual source paths from the repository.


Building the Foundation: The FunctionRegistryTrie

Every definition in the parsed codebase enters a trie‑backed registry defined in codebase_rag/function_registry.py. The FunctionRegistryTrie class provides hierarchical storage and multiple auxiliary indices for fast lookup.

Core Data Structures

The trie stores QNs in a nested dictionary (root) while maintaining parallel indices:

  • _entries — direct map of QN → NodeType (FUNCTION, CLASS, METHOD, etc.)
  • _simple_name_lookup — maps bare names to sets of possible QNs
  • _ending_with_cache — caches results for suffix‑based queries
  • _duplicates and _variant_columns — handle same‑line duplicate definitions
  • _properties, _abstracts, _callable_params — mark special callable attributes

During parsing, the insert method walks the QN parts (split by dots), creates missing trie nodes, and populates all indices. This upfront indexing is what makes cross‑reference resolution fast enough for large monorepos.


The Resolution Pipeline: From Call Text to QN

When codebase_rag/parsers/call_processor.py encounters a call expression, it delegates to the resolver via:

resolved = self._resolver.resolve_function_call(
    text, module_qn, local_var_types, class_context, caller_qn
)

The resolver implements a fallback cascade—each stage attempts resolution, and success short‑circuits the rest.

Stage 1: Exact QN Lookup

If the call text is already a fully qualified name (e.g., myproject.utils.do_work), the resolver queries registry.get(qn) directly. This is the fastest path and handles explicit imports and fully specified calls.

Stage 2: Simple‑Name Disambiguation

For bare identifiers like process, the resolver consults _simple_name_lookup. Two outcomes are possible:

  • Unique match — the single QN is returned immediately
  • Multiple candidates — the resolver uses local_var_types (built by build_local_variable_type_map) and class_context to filter based on the caller's scope

This local context is critical for languages with implicit self or closure captures.

Stage 3: Prefix + Suffix Matching

Chained access patterns like a.method() where a is an instance of pkg.subpkg.Class require trie navigation. The resolver calls find_with_prefix_and_suffix(prefix, suffix):

  1. Navigate to the node matching the prefix (pkg.subpkg.Class)
  2. Collect all QNs in that subtree ending with the suffix (.method)
  3. Return the matching method's QN

This handles the common case where the left side is a module or package and the right side is a method name.

Stage 4: Factory Inference

The call processor tracks factory patterns through _factory_calls. When it sees:

wrapper = factory()
wrapper.do()

It records the factory binding and later resolves the factory's QN to the concrete type it produces. The resolver may invoke _infer_free_function_return_type to determine the return type when static analysis alone is insufficient.

Stage 5: Bound Callable Peeling

For languages with explicit bound references (method groups in C#, fn.bind(this) in JavaScript), the resolver uses _peel_bound_callable to extract the underlying function QN before lookup.

Output: CALLS Edge Creation

Successful resolution returns (NodeLabel, resolved_qn). The CallProcessor then emits a CALLS relationship (defined in codebase_rag/types_defs.py as RelationshipType.CALLS) between caller_qn and the resolved callee, ingested into Neo4j by codebase_rag/graph_updater.py.


Code Examples: Resolution in Practice

Exact Qualified Name Resolution


# Registration during parsing

caller_qn = "myproject.utils.helper"
callee_qn = "myproject.utils.do_work"
registry[callee_qn] = cs.NodeType.FUNCTION

# Resolution of fully qualified call

resolved = resolver.resolve_function_call(
    "myproject.utils.do_work",
    "myproject.utils",
    None,
    None,
    caller_qn
)

# Result: (NodeLabel.FUNCTION, "myproject.utils.do_work")

Simple‑Name Disambiguation with Local Context


# Registry contains one "process" method

registry["myproject.services.Service.process"] = cs.NodeType.FUNCTION

# Local variable map provides type context

local_vars = {"svc": "myproject.services.Service"}

resolved = resolver.resolve_function_call(
    "process",
    "myproject.services",
    local_vars,
    None,
    caller_qn
)

# Result: (NodeLabel.FUNCTION, "myproject.services.Service.process")

Prefix + Suffix Method Resolution


# Class and method registered

registry["pkg.subpkg.Class"] = cs.NodeType.CLASS
registry["pkg.subpkg.Class.method"] = cs.NodeType.METHOD

# Instance method call: a.method() where a: pkg.subpkg.Class

resolved = resolver.resolve_function_call(
    "method",
    "pkg.subpkg.Class",
    None,
    "pkg.subpkg.Class",
    caller_qn
)

# Trie finds prefix "pkg.subpkg.Class", suffix ".method"

# Result: (NodeLabel.METHOD, "pkg.subpkg.Class.method")

Factory Return‑Type Inference


# Factory function registered

registry["myproject.factories.factory"] = cs.NodeType.FUNCTION

# Call to factory—resolver infers concrete return type

resolved = resolver.resolve_function_call(
    "factory",
    "myproject.factories",
    None,
    None,
    caller_qn
)

# Result: QN of concrete type produced by factory, used for subsequent CALLS edges

Cross‑Language Consistency

The resolver is stateless apart from the shared FunctionRegistryTrie. All language frontends—Python (codebase_rag/parsers/py/ast_analyzer.py), TypeScript, Rust, Go, C++, and others—feed into the same registry and use the same resolution logic.

This design guarantees that a CALLS edge from Python to a Rust FFI function resolves identically to an edge within a single language, enabling accurate cross‑language dependency analysis in polyglot codebases.


Key Files and Their Roles

File Purpose
codebase_rag/function_registry.py FunctionRegistryTrie with find_with_prefix_and_suffix, find_ending_with, and direct lookups
codebase_rag/parsers/call_processor.py Orchestrates call‑site analysis and resolver invocation
codebase_rag/parsers/py/ast_analyzer.py Python‑specific class resolution, self/cls handling, return‑type inference
codebase_rag/parsers/frontends.py Supplies AST nodes and query cursors for tree‑sitter parsing
codebase_rag/types_defs.py Schema constants: NodeType, RelationshipType.CALLS
codebase_rag/graph_updater.py Coordinates parsing, registry population, and Neo4j ingestion

Summary

  • Knowledge graph construction relies on a trie‑backed registry in function_registry.py that indexes all qualified names with multiple lookup strategies.
  • Function call resolution follows a six‑stage cascade: exact QN, simple‑name with disambiguation, prefix‑suffix matching, factory inference, bound‑callable peeling, and return‑type fallback.
  • CALLS edge emission connects caller and callee QNs in Neo4j, enabling graph traversal for impact analysis and code search.
  • Cross‑language support emerges from sharing one stateless resolver across all parser frontends.

Frequently Asked Questions

How does code-graph-rag handle ambiguous simple names?

The resolver checks _simple_name_lookup for all candidates, then uses local_var_types (variable‑to‑type mappings from the caller's scope) and class_context to filter to the correct target. If ambiguity remains, it falls back to return‑type inference or flags the call for manual review.

What happens when a factory function returns different types conditionally?

The resolver records factory bindings in _factory_calls and attempts _infer_free_function_return_type. For conditional returns, the engine may emit edges to multiple concrete types or to an abstract base class, depending on static analysis depth. The CallProcessor can be configured with heuristics for conservative or aggressive factory expansion.

Can the trie registry handle very large codebases?

Yes—the hierarchical trie structure provides O(prefix length) navigation, and the auxiliary indices (_simple_name_lookup, _ending_with_cache) trade memory for speed. The implementation in function_registry.py is designed for incremental updates, allowing the registry to grow as new files are parsed without rebuilding from scratch.

How does prefix + suffix resolution differ from exact QN lookup?

Exact QN lookup requires the full qualified name as text—fastest but requires fully specified imports. Prefix + suffix resolution navigates to a known scope (the prefix, often a class or module) then searches for members matching the suffix. This handles the common pattern instance.method() where only the instance's type is known, not the method's full QN.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →