Repository navigation
Go: calls through a struct field are not resolved when the receiver type is unexported #2323
Description
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
Phase 4: resolution and extraction accuracy
fix(go): resolve unexported receivers and nested modules within the r…
colbymchenry commentedon Oct 5, 2026
Thanks @GoDiao for the diagnosis, the numbers on four real repos, and #2325. Your anchored pattern is the core of the fix.
Fixed on
mainin #2361, together with #2322. Applying #2325 alone onmainshowed 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'sserver/handler type. For example, 653 handlerSendErrorcalls went tocommon/api'sBaseAPIinstead of the handler package's own. So #2361 adds: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.BaseAPI,*cached.BaseManager).On your repro,
callers AddItemnow listsCreatefor the lowercaseservertoo. 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.