Skip to content

Go: calls through a struct field are not resolved when the receiver type is unexported #2323

Closed

Description

@GoDiao

When a method's receiver type is unexported (func (s *server) ...), calls through one of its fields, such as s.service.AddItem(), are not resolved. Renaming the type to Server makes the same call resolve. Unexported receiver types are idiomatic Go and common for gRPC/HTTP handler structs, so this drops a lot of edges in typical code.

Reproduction

go.mod is at the root, so this is independent of #2322.

// internal/domain/service.go
package domain

type Service struct{}

func (s *Service) AddItem(name string) error { return nil }
// internal/handlers/server.go
package handlers

import "example.com/app/internal/domain"

type server struct {
	service *domain.Service
}

func (s *server) Create(name string) error {
	return s.service.AddItem(name)
}
repro.sh
#!/bin/sh
# Same code indexed twice: the handler struct is `server` in one copy and `Server` in the other.
set -e
B=$(mktemp -d)
mk() {
  M="$B/$1"
  mkdir -p "$M/internal/domain" "$M/internal/handlers"
  printf 'module example.com/app\n\ngo 1.22\n' > "$M/go.mod"
  cat > "$M/internal/domain/service.go" <<'GO'
package domain

type Service struct{}

func (s *Service) AddItem(name string) error { return nil }
GO
  cat > "$M/internal/handlers/server.go" <<GO
package handlers

import "example.com/app/internal/domain"

type $2 struct {
	service *domain.Service
}

func (s *$2) Create(name string) error {
	return s.service.AddItem(name)
}
GO
}
mk lower server
mk upper Server
for r in lower upper; do
  codegraph init "$B/$r" >/dev/null 2>&1
  echo "== $r: callers of AddItem"
  codegraph callers --path "$B/$r" AddItem | grep -v '^$'
done
Receiver type callers of AddItem
server 0
Server 1 (Create)

Tested with CodeGraph 1.6.1.

Cause

matchGoFieldChainCall first infers the type of s with inferLocalReceiverType. For Go, the only pattern that matches a method receiver is the typed-parameter one in localReceiverTypePatterns:

new RegExp(`\\b${r}\\s+\\*?([A-Z][\\w.]*)`), // func use(lg Logger) / (l Logger)

It requires an uppercase first letter, as a guard against matching unrelated ident Type pairs. So (s *server) yields no type, and the chain is dropped before the field lookup. The field step itself already accepts unexported types (the tree *node case from chi).

Suggested fix

Add a pattern anchored on func (, which allows a lowercase type without the false-positive risk the guard protects against:

new RegExp(`\\bfunc\\s*\\(\\s*${r}\\s+\\*?([A-Za-z_]\\w*)\\s*(?:\\[[^\\]]*\\])?\\s*\\)`), // func (s *server) / (s *store[T])

The existing uppercase-guarded pattern stays as it is for plain parameters. Lowercase types in plain parameters (func f(h *handler)) have the same problem, but relaxing that is a separate question.

Measured impact

With only this pattern added, indexing four public repos (shallow clones, CodeGraph 1.6.1, .pb.go excluded):

Repo (commit) Methods with unexported receiver Go→Go call edges x.field.Method() calls Edges removed
prometheus/prometheus (961c9ba) 39% 38,990 → 40,120 (+1,123) 1,011 → 1,557 0
etcd-io/etcd (64f26db) 64% 16,323 → 17,035 (+707) 175 → 601 0
goharbor/harbor (f25e9da), src/ 48% 21,656 → 24,985 (+3,328) 1,323 → 3,631 0
mattermost/mattermost (af4c3cf), server/ 11% 122,459 → 122,999 (+542) 7,938 → 8,405 2

I checked a random sample of 32 added edges against the source and all were correct, including calls through embedded structs and to interface methods. The 2 removed edges in mattermost were wrong before and now point to the right method. For example, src.set(cfg) inside func (src *fileSrc) Set used to resolve to jsonSrc::set and now resolves to fileSrc::set.

I can open a PR with this change and a regression test.

Activity

  1. colbymchenry commented on Oct 5, 2026

    @colbymchenry
    Owner

    Thanks @GoDiao for the diagnosis, the numbers on four real repos, and #2325. Your anchored pattern is the core of the fix.

    Fixed on main in #2361, together with #2322. Applying #2325 alone on main showed something new. With unexported receiver types typed, a call to a method went to whichever same-named type the name lookup found, and Go type names are only unique within a package. On harbor, 1,042 of the 1,998 new calls landed on another package's server/handler type. For example, 653 handler SendError calls went to common/api's BaseAPI instead of the handler package's own. So #2361 adds:

    • Lowercase receivers and parameters. A lowercase type is now read wherever a parameter list puts it, both receivers (func (s *server), func (c *cache[T])) and plain parameters (func handle(h *handler)). Your pattern is extended to plain parameters, which the validation supported.
    • Package-scoped method lookup. A method is looked up in the package that declares the receiver's type, then in the types that type embeds, read from its declaration (BaseAPI, *cached.BaseManager).

    On your repro, callers AddItem now lists Create for the lowercase server too. Across etcd, harbor and prometheus, Go call edges grew by roughly 7,200, 8,900 and 1,400. About 1,500 existing edges moved to the right package's type, and every removal checked was a correction.

    This ships in the next release. Re-index after upgrading (codegraph index) to pick it up. You're credited in the changelog and as a co-author on the commit.

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

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions