Skip to content

Go member calls resolve to nothing across files, and cannot cross a repo boundary at all #3393

Closed

Description

@xiongjianxu

Net-new over v8 (verified @6478eb71, 0.9.80)

v8 today has no Go receiver typing at all: no _resolve_go_member_calls and no
go_member_calls registration in graphify/extract.py; extractors/go.py carries no
receiver name/type through function_bodies and stamps no _callable/_callable_class
on Go nodes; and go is absent from _LANG_SUFFIXES in cross_repo_calls.py (which
lists only cpp/csharp/java/swift). The recent member-call work on v8 is Python (#4198,
#2860), C# (#3797) and PowerShell (#4163) — none of it Go.

Still missing, and what the paired PR adds: resolve recv.M() through the receiver's
declared type to a method in another file, the go.py plumbing + _callable_class
markers that requires, and parking the call so a merged graph can finish it (#3152).
Fully net-new; nothing here is pre-empted.


What happens

A Go method call on a receiver resolves only when the bare method name happens to
match a symbol declared in the caller's own file. Anything else produces no edge:

// svc/greeter.go
package svc

type Greeter struct{}

func (g *Greeter) Greet() {}
// svc/app.go
package svc

type App struct {
	greeter *Greeter
}

func (a *App) Run() { a.greeter.Greet() }   // no `calls` edge

extract_go reads the receiver's identifier and discards it unless it names an
imported package, so the shape Go dependency injection actually takes — a struct
field used from a method — contributes nothing to the call graph. The shared
cross-file pass then skips member callees outright (obj.log() → "log" has no
import evidence), and no _resolve_go_* resolver is registered to pick them up.
No test in the repo asserts that a cross-file Go receiver method call yields an
edge; test_go_builtin_call_targets.py states the omission explicitly.

Merging two repos loses the same edges for the same reason, plus one more: Go
nodes never carry the _callable / _callable_class markers (#2438), because
extract_go is the one extractor outside the tree-sitter engine and the engine is
the only place that stamps them. link_cross_repo_member_calls requires
_callable_class on the declaring type, so a Go type could not answer a parked
call even if one existed.

What it should do

Bind the call through the receiver's declared type, as the Swift (#1356), C++
(#1547), ObjC (#1556) and C# (#1609) resolvers already do for their languages —
and when the type is declared nowhere in the build, park the call so a merged
graph can finish it (#3152).

Go writes the receiver's type down in four places, all of them cheap to read:

shape source
type App struct { greeter *Greeter } struct field
func (s *Server) M() / func f(g *Greeter) parameter
var g Greeter var declaration
g := Greeter{} / g := &Greeter{} composite literal

The single-definition guard has to stay: a Go type node id folds in the package
directory, so two packages declaring Greeter are two nodes and without import
evidence neither one is the answer.

Why it matters

Go is a backend language and services are split across repositories; the call
graph across that boundary is the reason to merge two graphs at all. Together with
the ObjC (#3384), TS/JS (#3386), Kotlin (#3388) and PHP (#3390) gaps, this covers
the languages a mobile-plus-backend org actually ships.

PR to follow.

Activity

  1. xiongjianxu commented on Sep 22, 2026

    @xiongjianxu
    ContributorAuthor

    Here is a self-contained, runnable reproduction (no module/toolchain setup — just graphify on PYTHONPATH). It covers both halves: the cross-file gap in a single repo and the cross-repo gap after merge-graphs.

    # repro_3393.py  —  python repro_3393.py   (exit 0 = fixed, 1 = bug present)
    import json, subprocess, sys, tempfile
    from pathlib import Path
    from graphify.extract import extract
    
    APP_GO = ("package svc\n\ntype App struct {\n\tgreeter *Greeter\n}\n\n"
              "func (a *App) Run() {\n\ta.greeter.Greet()\n}\n")
    GREETER_GO = "package svc\n\ntype Greeter struct{}\n\nfunc (g *Greeter) Greet() {}\n"
    
    def _extract(root):
        return extract(sorted(root.rglob("*.go")), cache_root=root / "graphify-out")
    
    def _calls(res):
        nodes = {n["id"]: n for n in res["nodes"]}
        return [(nodes[e["source"]]["label"], nodes[e["target"]]["label"])
                for e in res["edges"] if e.get("relation") == "calls"]
    
    def _write(path, res):
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(json.dumps({"directed": False, "multigraph": False, "graph": {},
                                    "nodes": res["nodes"], "links": res["edges"]}))
    
    def main():
        with tempfile.TemporaryDirectory() as d:
            tmp = Path(d)
            # A. cross-file, one repo
            (tmp / "single/svc").mkdir(parents=True)
            (tmp / "single/svc/app.go").write_text(APP_GO)
            (tmp / "single/svc/greeter.go").write_text(GREETER_GO)
            calls = _calls(_extract(tmp / "single"))
            a = any("Run" in s and "Greet" in t for s, t in calls)
            print("[A] cross-file calls edges:", calls or "(none)")
    
            # B. cross-repo, merge-graphs
            (tmp / "app/svc").mkdir(parents=True); (tmp / "lib/svc").mkdir(parents=True)
            (tmp / "app/svc/app.go").write_text(APP_GO)
            (tmp / "lib/svc/greeter.go").write_text(GREETER_GO)
            ag, bg = tmp / "app/graphify-out/graph.json", tmp / "lib/graphify-out/graph.json"
            _write(ag, _extract(tmp / "app")); _write(bg, _extract(tmp / "lib"))
            parked = [e for n in json.loads(ag.read_text())["nodes"]
                      if isinstance(n.get("metadata"), dict)
                      for e in n["metadata"].get("unresolved_calls", [])]
            print("[B] parked unresolved_calls:", parked or "(none)")
            out = tmp / "merged.json"
            subprocess.run([sys.executable, "-m", "graphify", "merge-graphs",
                            str(ag), str(bg), "--out", str(out)], cwd=tmp, check=True)
            xr = [(e["source"], e["target"]) for e in json.loads(out.read_text())["links"]
                  if e.get("relation") == "calls" and e.get("context") == "cross_repo"]
            print("[B] cross_repo calls edges:", xr or "(none)")
            return 0 if (a and xr) else 1
    
    sys.exit(main())

    On pristine v8 (20a20d3):

    [A] cross-file calls edges: (none)
    [B] parked unresolved_calls: (none)
    [B] cross_repo calls edges: (none)
    RESULT: cross-file = False  cross-repo = False   (exit 1)
    

    The a.greeter.Greet() call (receiver is a struct field of type *Greeter) produces no calls edge even when Greeter is declared in a sibling file, and nothing is parked, so merge-graphs cannot recover it across repos either — exactly the two shapes described above.

    With PR #3395 (feat/go-cross-repo-member-calls):

    [A] cross-file calls edges: [('.Run()', '.Greet()')]
    [B] parked unresolved_calls: [{'callee': 'Greet', 'receiver_type': 'Greeter', 'lang': 'go', 'line': 'L8'}]
    [B] cross_repo calls edges: [('app::svc_app_run', 'lib::svc_greeter_greet')]
    RESULT: cross-file = True  cross-repo = True   (exit 0)
    

    Cross-file is resolved directly; cross-repo is parked by name (no node ids, per the #3152 contract) and finished as an INFERRED / context=cross_repo edge by link_cross_repo_member_calls.

    PR #3395 cherry-picks clean onto the current v8 tip 20a20d3 (git merge-tree reports no conflicts; PR is MERGEABLE / CLEAN), so it can be picked up as-is or rebased on request.

  2. safishamsi commented on Oct 13, 2026

    @safishamsi
    Member

    Fixed in v0.9.89 (graphifyy 0.9.89 on PyPI) via #3395.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions