Repository navigation
[Golang] Method calls on typed receivers produce no calls edges #3368
Description
Summary
The Golang extractor produces no calls edges for method calls on typed receivers.
This affects typed function parameters, struct fields of any type, and chained
selector calls. All three patterns are standard in Golang service code.
Root Cause
graphify/extractors/go.py, walk_calls marks every non-package-alias receiver as is_member_call=True:
is_member_call = receiver_name not in go_imported_pkgsgraphify/symbol_resolution.py, resolve_cross_file_raw_calls drops every raw call with that flag:
if raw_call.get("is_member_call"):
continue # dropped unconditionallyNo Golang receiver-type resolver exists. The extractor records is_member_call=True but no receiver_type. The resolver has nothing to match. No edge is emitted.
Reproduction
Use mindbenders (https://github.com/vkstack/mindbenders) to reproduce.
- Clone the repository.
- Run
graphify update .in the project root. - Run
graphify query "calls from UploadProfile". - Run
graphify query "calls from zapWrite". - Run
graphify query "calls from Eval".
The graph shows no calls edges for any of the call sites in Affected Patterns.
Affected Patterns
Pattern A — Typed function parameter
limiter is a *redis_rate.Limiter parameter. It is not a package alias. The extractor marks limiter.Allow(...) as is_member_call=True. The edge is dropped.
// mindbenders/ratelimiter/leakybucket.go:23
func Eval(ctx context.Context, limiter *redis_rate.Limiter, ...) bool {
res, err := limiter.Allow(ctx, key, limit) // no calls edge
...
}Pattern B — Struct field
The field type does not matter. Interface, pointer-to-struct, concrete-value, and func-typed fields all produce the same result.
// mindbenders/profiler/profiler.go:63,218 — interface field
type profiler struct{ uploader uploader.IProfileUploader }
p.uploader.UploadProfile(target, bytes.NewReader(prof.Data)) // no calls edge
// mindbenders/logging/zap.go:71,73 — pointer-to-struct field
type dlogger struct{ zap *zap.Logger }
entry := dLogger.zap.Check(zlevel, MessageKey) // no calls edge
// mindbenders/profiler/profiler.go:59,108,110 — concrete-value field
type profiler struct{ wg sync.WaitGroup }
p.wg.Add(1) // no calls edge
p.wg.Done() // no calls edge
// mindbenders/logging/logger.go:23,94 — func-typed field
type dlogger struct{ writer func(Fields, Level, string) }
dLogger.writer(fields, cb, MessageKey) // no calls edgePattern C — Selector chain
tree-sitter parses a.b.c() as nested selector_expression nodes. The extractor reads the outer operand as a multi-segment string ("b.Start"). That string is not in go_imported_pkgs. The call is dropped.
// mindbenders/profiler/profiler.go:210,214,216
b.Start = b.Start.Local() // receiver_name = "b.Start" → dropped
target = b.Start.Format("2006-01-02/15:04:05") + ... // receiver_name = "b.Start" → dropped
target = p.cfg.OutputDirFn(b.Start) + ... // receiver_name = "p.cfg" → droppedPattern D — Interface dispatch
The member-call fix (Patterns A–C) produces a calls edge to the interface method node. No implements edge connects the interface to its concrete type. The graph stops at the interface boundary.
In C#, csharp_dispatch.py walks implements edges (emitted from the explicit class Foo : IBar declaration) and adds dispatches_to edges from interface methods to concrete methods. No equivalent exists for Go.
Go has no implements keyword. The extractor must compare method sets to find which struct types implement which interfaces. If T's method set is a superset of I's method set, emit T --implements--> I. Then a go_dispatch.py resolver follows csharp_dispatch.py's pattern to emit dispatches_to edges.
// After Pattern B fix: profiler.UploadProfile --calls--> IProfileUploader.UploadProfile
// Still missing: ConcreteUploader --implements--> IProfileUploader
// Still missing: IProfileUploader.UploadProfile --dispatches_to--> ConcreteUploader.UploadProfileOut of scope: Field accesses as values (x := a.b, val := a.b.c) are not call_expression nodes. They need a separate reads edge. They are not part of this issue.
Why It Matters
Golang service code uses interfaces and struct composition for all major component boundaries. Patterns A, B, and C cover the majority of cross-component calls in a Golang service.
In mindbenders, these edges are absent from the graph:
profiler.UploadProfile→IProfileUploader.UploadProfileprofiler.run→metrics.resetprofiler.stop→sync.WaitGroup.Wait,sync.Once.Dodlogger.zapWrite→zap.Logger.Checkdlogger.write→dlogger.writerEval→redis_rate.Limiter.Allow
The call graph for a Golang service is near empty without this fix. Dependency analysis, dead-code detection, and security audits all depend on complete call edges.
Even after Pattern A–C edges land, every call through an interface field stops at the interface node. Golang service code uses interface injection at every component boundary. Navigation from a caller to a concrete implementation is not possible without implements and dispatches_to edges.
Suggested Fix
The fix follows the approach in PR #3240 (C++ parameter receiver fix).
Step 1 — Build a receiver-type table during extraction.
- From each
function_declaration: map parameter names to their declared types. - From each
method_declaration: map the receiver to its struct type. Read the struct definition. Map each field name to its declared type. - Map
*TandTto the same nameT. Mark func-typed fields with afuncmarker distinct from method entries.
Result: go_receiver_type_table = { name → type_string }
Step 2 — Resolve the receiver chain during call extraction.
If is_member_call=True, split receiver_name on .:
- One segment: look it up in the table. Record
receiver_type. - Two segments: look up segment 0 for the struct type. Look up segment 1 as a field name. Record the field type.
- Three or more: resolve each segment in order. Record the final type.
Strip * at each step.
Step 3 — Add _resolve_go_member_calls in symbol_resolution.py.
Match (receiver_type, callee_name) against the Golang method index. Emit relation="calls", confidence="INFERRED" edges. This follows the same contract as the Kotlin resolver in PR #1965.
Step 4 — Build implements edges by method-set comparison.
For each interface_type declaration, collect the method set: names and parameter counts. For each named struct_type, collect its method set. For each pair where T's method set is a superset of I's method set, emit T --implements--> I with confidence="INFERRED". This step runs after all files in the project are extracted.
Step 5 — Add go_dispatch.py, following csharp_dispatch.py.
Walk implements edges from Step 4. For each interface with exactly one implementing type, match method names. Emit IFace.Method --dispatches_to--> Concrete.Method with confidence="INFERRED". Do not emit dispatches_to edges when multiple types implement the same interface.
Implementation order: Pattern A (single-segment) → Pattern B (two-segment, one field lookup) → Pattern C (deep chains, iterative lookup) → Step 4 (implements edges, post-extraction) → Step 5 (go_dispatch.py, requires Step 4).
Related
| Reference | Description |
|---|---|
| #2041 | Canonical cross-language tracking issue for member-call gap |
| #1313 | Golang cross-package qualified calls (pkg.Func()) — a distinct problem |
| PR #3240 | C++ parameter receiver fix — reference implementation |
| PR #1965 | Kotlin receiver-typed resolver — reference implementation |
| #2234 | Same gap in Rust |
Runtime
| Field | Value |
|---|---|
| graphify | 0.9.53 |
| tree-sitter | 0.25.2 |
| tree-sitter-go | 0.25.0 |
| Python | 3.13 |
| OS | macOS 25.5.0 (darwin arm64) |
| Codebase analyzed | https://github.com/vkstack/mindbenders |
Activity
vkstack commentedon Sep 6, 2026
I am working on this and raise a PR soon
fix(go): normalize generic receiver ids; capture every multi-name field
dKKpandey commentedon Oct 13, 2026
The core problem here was already fixed by @xiongjianxu's cherry-picked
feat(go): resolve member calls through the receiver's declared type(1695efae, in0.9.89) — it resolves member calls through struct fields, parameters,vardeclarations, and composite literals, more completely than I'd originally scoped a duplicate PR for.While verifying that fix against this issue's examples, I found one gap it left: a generic type's (
Box[T]) methods attach to a node disconnected from the type's own declaration, soBoxitself shows zero methods. Opened #4438 for that specific piece.Given the member-call resolution this issue asked for is shipped, I think this can close once #4438 lands (or now, if the generic-receiver gap is tracked separately) — your call.
fix(go): normalize a generic receiver type before building its id
omsenjalia commentedon Oct 18, 2026
This looks fixed on the
v8branch by 1695efae (feat(go): resolve member calls through the receiver's declared type). Could a maintainer confirm and close?fix(go): type a receiver's field when its struct is declared in a sib…
safishamsi commentedon Oct 30, 2026
Partly fixed in v0.9.99 via #4866: a receiver's field whose struct is declared in a sibling file of the same package is now typed, so its member calls resolve. The cross-package qualified-receiver half (
*pkg.Tfields/params) is tracked in #4853 and not yet landed, so keeping this open until that ships.fix(go): resolve member calls on receivers typed from another package (…
fix(go): link a method value reached through a receiver field (#1566)