Skip to content

[Golang] Method calls on typed receivers produce no calls edges #3368

Description

@vkstack

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_pkgs

graphify/symbol_resolution.py, resolve_cross_file_raw_calls drops every raw call with that flag:

if raw_call.get("is_member_call"):
    continue   # dropped unconditionally

No 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.

  1. Clone the repository.
  2. Run graphify update . in the project root.
  3. Run graphify query "calls from UploadProfile".
  4. Run graphify query "calls from zapWrite".
  5. 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 edge

Pattern 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"   → dropped

Pattern 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.UploadProfile

Out 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.UploadProfileIProfileUploader.UploadProfile
  • profiler.runmetrics.reset
  • profiler.stopsync.WaitGroup.Wait, sync.Once.Do
  • dlogger.zapWritezap.Logger.Check
  • dlogger.writedlogger.writer
  • Evalredis_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 *T and T to the same name T. Mark func-typed fields with a func marker 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

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