Skip to content

Go: cross-package calls are not resolved when go.mod is not at the project root #2322

Description

@GoDiao

When a Go module lives in a subdirectory of the indexed project (for example svc/go.mod with no go.mod at the root), cross-package calls inside that module are not resolved. callers returns no results for both package-qualified calls (store.New()) and calls made through a struct field (s.db.CreateItem()). Indexing the same code with go.mod at the root resolves both.

This layout is common in repos that keep a Go backend next to a frontend or other services, e.g. server/go.mod and web/package.json.

Reproduction

Two copies of the same module, one with go.mod at the root and one in svc/. Both build with go build ./....

// internal/store/store.go
package store

type Manager struct{}

func New() *Manager { return &Manager{} }

func (m *Manager) CreateItem(name string) error { return nil }
// internal/domain/service.go
package domain

import "example.com/app/svc/internal/store"

type Service struct {
	db *store.Manager
}

func NewService() *Service {
	return &Service{db: store.New()}
}

func (s *Service) AddItem(name string) error {
	return s.db.CreateItem(name)
}
repro.sh
#!/bin/sh
# Same Go module indexed twice: once with go.mod at the repo root, once with it in a subdirectory.
set -e
B=$(mktemp -d)
mk() {
  M="$1/$2"
  mkdir -p "$M/internal/store" "$M/internal/domain"
  printf 'module example.com/app/svc\n\ngo 1.22\n' > "$M/go.mod"
  cat > "$M/internal/store/store.go" <<'GO'
package store

type Manager struct{}

func New() *Manager { return &Manager{} }

func (m *Manager) CreateItem(name string) error { return nil }
GO
  cat > "$M/internal/domain/service.go" <<'GO'
package domain

import "example.com/app/svc/internal/store"

type Service struct {
	db *store.Manager
}

func NewService() *Service {
	return &Service{db: store.New()}
}

func (s *Service) AddItem(name string) error {
	return s.db.CreateItem(name)
}
GO
}
mk "$B/root" .
mk "$B/nested" svc
for r in root nested; do
  codegraph init "$B/$r" >/dev/null 2>&1
  for s in New CreateItem; do
    echo "== $r: callers of $s"
    codegraph callers --path "$B/$r" "$s" | grep -v '^$'
  done
done
Call go.mod at root go.mod in svc/
store.New(), callers of New 1 (NewService) 0
s.db.CreateItem(), callers of CreateItem 1 (AddItem) 0

Tested with CodeGraph 1.6.1.

Cause

loadGoModule in src/resolution/go-module.ts reads only <projectRoot>/go.mod, and its comment notes that nested go.mod files are not yet supported. With no module path, in-module imports such as example.com/app/svc/internal/store are treated as external, so:

  • package-qualified calls fall back to name matching and are dropped;
  • matchGoFieldChainCall skips the package-qualified field type store.Manager because the package is not in-module.

Possible fix

Discover every go.mod under the project root (and possibly go.work), and have getGoModule return the module whose directory contains the file being resolved, instead of a single project-wide module. I'm happy to open a PR with a regression test if this direction works for you.

Activity

  1. GoDiao commented on Oct 3, 2026

    @GoDiao
    ContributorAuthor

    Some numbers from public repos, to show how often this comes up. All measured with CodeGraph 1.6.1 on shallow clones; "module-only" means indexing just the directory that contains go.mod, which is what a fix would give.

    Repo (commit) Layout Go→Go call edges, whole repo → module-only Cross-directory calls x.field.Method() calls
    goharbor/harbor (f25e9da) only src/go.mod 16,308 → 21,656 (+33%) 8,963 → 14,268 (+59%) 798 → 1,323
    mattermost/mattermost (af4c3cf) server/go.mod, plus server/public/go.mod 114,068 → 122,459 (+7%) 51,719 → 59,848 (+16%) 2,395 → 7,938

    Generated .pb.go files are excluded. The mattermost figure is a lower bound: server/public is a separate module, so even the server/-only index still treats it as external.

    The same problem also appears with a root go.mod, when the repo has several modules. etcd-io/etcd (64f26db) has go.mod at the root (go.etcd.io/etcd/v3) and sub-modules such as server/go.mod (go.etcd.io/etcd/server/v3) and client/v3/go.mod. The sub-module paths are not under the root module path, so every import of them is treated as external: 2,111 import lines across 704 files, with only 2 using the root module path. For example, from etcdutl/ into server/, calls such as wal.OpenForRead(...), backend.NewDefaultBackend(...) and schema.Migrate(...) get no import-resolved edge (0 in the index).

    Given that case, I'd adjust the fix direction from my original post: instead of picking the module that contains the file being resolved, keep a list of all modules found under the root and map an import path to a directory by the longest matching module path. That covers both layouts (nested module, and root plus sibling modules), and the resolver does not need the calling file's location for it. Still happy to open a PR with tests for this if it sounds right.

  2. colbymchenry commented on Oct 5, 2026

    @colbymchenry
    Owner

    Thanks @GoDiao for the report, and for the follow-up about multi-module repos like etcd. It changed the design for the better.

    Fixed on main in #2361, building on #2324 by @danusha2345:

    • Every module is found. For each directory with indexed Go files, the resolver reads the nearest go.mod, never above the project root. An import path then maps to the module with the longest matching module path, as you suggested. This covers a nested module (svc/go.mod, a server/ backend next to web/) and sibling modules (etcd's root module beside server/go.mod and client/v3/go.mod). It discovers modules from the indexed files, so it never walks the disk, and modules under testdata/ or _/.-prefixed directories only serve their own files.
    • Names resolve within their package. A name written through a package, like store.Manager, job.OPCommand or artifact.Manager, now links only to that package's symbol, not to a same-named one elsewhere. That was needed because module discovery alone sent some type references to same-named methods: about 180 on etcd. It also corrects links in projects with a single go.mod.

    On your repro, callers New and callers CreateItem now find NewService and AddItem with go.mod in svc/, and the root-go.mod variant is unchanged. Go→Go edges went from 52,530 to 66,284 on etcd and from 62,423 to 77,615 on harbor. Every removed edge checked was a correction.

    This ships in the next release. Re-index after upgrading (codegraph index) to pick it up. You and @danusha2345 are credited in the changelog and as co-authors 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