Skip to content

query_graph callers_of misses cross-file callers when any same-file caller exists #472

@JackCYG

Description

@JackCYG

query_graph(pattern="callers_of", ...) returns incomplete results for Objective-C and C codebases. When a function has both same-file callers (edges with fully-qualified target_qualified) and cross-file callers (edges with bare target_qualified), only the same-file callers are returned. The cross-file callers are silently dropped.

Root Cause

In code_review_graph/tools/query.py, the callers_of branch (line ~226):

if pattern == "callers_of":
    for e in store.get_edges_by_target(qn):
        if e.kind == "CALLS":
            caller = store.get_node(e.source_qualified)
            if caller:
                results.append(node_to_dict(caller))
            edges_out.append(edge_to_dict(e))
    # Fallback: search by plain name for unqualified targets
    if not results and node:                    # <--- BUG
        for e in store.search_edges_by_target_name(node.name):
            ...

The fallback search_edges_by_target_name only executes when results is empty. But in ObjC/C, the parser (_resolve_call_target in parser.py) generates:

  • Fully-qualified targets (e.g. file.m::ClassName.methodName) when the callee is defined in the same file (present in defined_names)
  • Bare targets (e.g. methodName) when the callee is defined in a different file (not in defined_names, not in import_map)

Since get_edges_by_target(qn) matches the qualified edges first, results becomes non-empty, and the fallback that would catch the bare-target cross-file edges is skipped.

Impact

  • 87.2% of CALLS edges in the affected codebase have bare (unqualified) targets
  • Any callers_of query that has ≥1 same-file caller will miss all cross-file callers
  • This is especially severe for ObjC where Category methods are spread across many files — [self methodDefinedInAnotherCategory] always produces bare targets

Reproduction

Given this ObjC codebase structure:

// ClawGame.m (Category 1 - defines the method)
@implementation KSKTVRoomViewController (ClawGame)
- (void)startLoadClawGameViewInfo { ... }     // line 31: DEFINITION
- (void)handleClawGameMessage:(KSIMMessage *)message {
    [self startLoadClawGameViewInfo];          // line 99: same-file call → qualified target
}
@end

// RoomControl.m (Category 2 - calls the method)
@implementation KSKTVRoomViewController (RoomControl)
- (void)handleDelayAfterEnterRoom {
    [self startLoadClawGameViewInfo];          // line 3009: cross-file call → bare target
}
@end

After code-review-graph build:

# Query returns only 1 caller, should return 2
result = query_graph(pattern="callers_of", target="startLoadClawGameViewInfo")
assert len(result["results"]) == 1  # FAILS: expected 2

The database correctly contains both edges:

edge_id target_qualified file line
109958 ...ClawGame.m::KSKTVRoomViewController.startLoadClawGameViewInfo ClawGame.m 99
136509 startLoadClawGameViewInfo RoomControl.m 3009

But query_graph only returns edge 109958.

Proposed Fix

Change the fallback condition from if not results to always execute and deduplicate:

if pattern == "callers_of":
    seen_sources = set()
    for e in store.get_edges_by_target(qn):
        if e.kind == "CALLS":
            caller = store.get_node(e.source_qualified)
            if caller:
                results.append(node_to_dict(caller))
                seen_sources.add(e.source_qualified)
            edges_out.append(edge_to_dict(e))
    # Always search by plain name for unqualified targets, deduplicating
    if node:
        for e in store.search_edges_by_target_name(node.name):
            if e.source_qualified not in seen_sources:
                caller = store.get_node(e.source_qualified)
                if caller:
                    results.append(node_to_dict(caller))
                    seen_sources.add(e.source_qualified)
                edges_out.append(edge_to_dict(e))

The same fix should be applied to callees_of (which has the same structural issue when the callee's node isn't in the graph).

Environment

  • code-review-graph version: 2.3.3
  • Language: Objective-C (also affects C based on the same _resolve_call_target logic)
  • OS: macOS (arm64)
  • Python: 3.14

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions