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 ofQN → 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_duplicatesand_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 bybuild_local_variable_type_map) andclass_contextto 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):
- Navigate to the node matching the prefix (
pkg.subpkg.Class) - Collect all QNs in that subtree ending with the suffix (
.method) - 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.pythat 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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →