Skip to content

fix(go): resolve calls inside a Go module whose go.mod is below the project root (#2322) - #2324

Closed
danusha2345 wants to merge 1 commit into
colbymchenry:mainfrom
danusha2345:fix/2322-go-nested-module
Closed

danusha2345 wants to merge 1 commit into
colbymchenry:mainfrom
danusha2345:fix/2322-go-nested-module

Conversation

@danusha2345

Copy link
Copy Markdown
Contributor

Problem

loadGoModule read only <projectRoot>/go.mod. A module kept in a subdirectory, like svc/go.mod with no go.mod at the root, or a server/ backend next to a web/ frontend, had no module path. Its own imports (example.com/app/svc/internal/store) looked third-party, so with the issue's code:

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) }

callers New and callers CreateItem returned 0 with svc/go.mod, and 1 each with the same module at the root. resolveGoCrossPackageReference bailed out with no module, and matchGoFieldChainCall skipped the store.Manager field because the package did not count as in-module. Outside internal/, isGoExternalQualified also dropped the call as external.

Fix

  • The resolver now finds, for every directory that holds an indexed .go file, the nearest go.mod at or above it, up to the project root. This is memoized per directory, never walks the tree, and never leaves the project. The root go.mod is always included.
  • getGoModule() becomes getGoModuleForImport(importPath, fromFile?). It returns the module whose path equals the import path or is a /-bounded prefix of it. When modules nest (example.com/app and example.com/app/tools), the longest path wins, as in Go. When two modules declare the same path, the importing file's own module wins.
  • resolveGoCrossPackageReference maps the import to that module's directory plus the rest of the import path. Before, the rest was implicitly taken from the project root. The other three callers (isExternalImport, isGoExternalQualified, matchGoFieldChainCall) only ask whether the import is in the project.
  • The module list depends on the indexed file set, so clearCaches() drops it together with knownFiles. A module added later is picked up on the next sync. The per-directory go.mod reads keep the resolver-lifetime convention that goModule and dirAliases had.
  • With a single root go.mod, the module list is [root] and the package directory is the same string as before, so edges are unchanged.
  • EXTRACTION_VERSION 27 → 28, because stored edges change for these layouts.

The parallel resolver needs nothing extra: each worker builds its own ReferenceResolver over the same database and project root, and runs the same discovery.

Not in this PR

  • go.work is not read. Modules it lists inside the project are found anyway, because discovery starts from the Go files. use entries outside the project are not indexed.
  • GoFrame detection (frameworks/goframe.ts) still checks only the root go.mod for gogf/gf. The generic Go framework resolver already falls back to "any .go file".
  • A return or parameter type written as pkg.Type (func f() *tmapi.Client) is still matched by name only. It can land on a same-named type in another package. This happens with a root go.mod on main too; the instantiation &tmapi.Client{} resolves correctly. It accounts for 2 of the 294 new edges in the A/B below. Happy to follow up separately.

Tests

__tests__/go-nested-module.test.ts indexes real temp projects with SQLite and no mocks:

  • the issue's svc/go.mod layout: a package-qualified call into internal/, one into a non-internal package, and the struct-field call;
  • two sibling modules server/go.mod and tools/go.mod that both have an api.Start: each module's import resolves into its own api. server resolves a call and a struct-field call into tools through tools' module path, and an import that only shares a prefix (example.com/toolsx/api) stays unresolved;
  • two modules declaring the same path: each copy resolves into itself;
  • a root go.mod: unchanged.

5 of the 7 tests fail on main. The prefix guard and the root case pass on both. All 7 pass with the fix, with both kernel and wasm extraction. The existing Go, GoFrame and Gin suites, resolution.test.ts, frameworks*.test.ts and extraction.test.ts pass in both modes.

A/B (edges, main → fix)

  • The issue's repro script: New and CreateItem go from 0/0 to 1/1 callers with svc/go.mod. With the root go.mod they stay at 1/1.
  • A Wails desktop app with three Go modules in subdirectories and no root go.mod: +1 calls edge, the signaling server's diagreport.Main(...). That is its only cross-package call inside a module, and the edge is correct. No other edge changes.
  • A project with ten Go modules side by side, eight of which use a shared module through replace … => ../../relay (two of the ten declare the same module path): +248 calls, +16 instantiates, +30 references, 0 removed. Each added edge was checked against its call site:
    • 194 package-qualified calls, 16 instantiations and 28 type references land in the directory the import names;
    • 54 calls through struct fields land on the field's declared type;
    • 2 type references are the name-only case above.
  • An Android project with a root go.mod that carries the same modules in a subdirectory, plus a second module declaring the root's module path: the same +294, and nothing else changes.
  • Two single-module Go projects with go.mod at the root: all edges byte-identical.
  • Worker pool: a synthetic nested module with 500 packages and 25,000 calls resolves to the same edges with the pool forced on (CODEGRAPH_PARALLEL_RESOLVE_MIN=0, 15,000 refs resolved in workers) and with CODEGRAPH_NO_PARALLEL_RESOLVE=1. Main resolves 0 of them.

Thanks @GoDiao for the repro and the analysis in the issue; this follows the direction proposed there.

Fixes #2322

🤖 Generated with Claude Code

…roject root (colbymchenry#2322)

Only `<projectRoot>/go.mod` was read, so a module kept in a subdirectory
(`svc/go.mod`, a `server/` backend next to a `web/` frontend, several modules
side by side) had no module path: its own imports looked third-party and
both `store.New()` and `s.db.CreateItem()` lost their callers.

The resolver now finds, for every directory holding an indexed `.go` file,
the nearest `go.mod` at or above it up to the project root (memoized per
directory), and maps an import path to the module whose path equals it or
is a `/`-bounded prefix of it — the longest one when modules nest, the
importing file's own module when two declare the same path. The package
directory is that module's directory plus the rest of the import path. The
module list depends on the file set, so it is dropped with the other
file-set caches. A single root `go.mod` resolves exactly as before.

EXTRACTION_VERSION 27 -> 28: stored edges change for these layouts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
danusha2345 pushed a commit to danusha2345/codegraph that referenced this pull request Oct 3, 2026
Ports colbymchenry#1954's goImportedPackageDirs / goIsExternalImport onto colbymchenry#2324's
per-import module lookup (getGoModuleForImport), with a small
getGoModuleOfFile so a file outside every module still treats only the
standard library as certainly external. EXTRACTION_VERSION 40: Go edges
change for modules below the project root.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
danusha2345 pushed a commit to danusha2345/codegraph that referenced this pull request Oct 3, 2026
…odules below the project root) into fork main
@GoDiao

GoDiao commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

I re-ran the three public repos from my comment on #2322 against this branch (14af7e9), whole-repo index, .pb.go excluded:

Repo (commit) Go→Go call edges, main → this PR Cross-directory calls x.field.Method() calls
goharbor/harbor (f25e9da) 16,308 → 21,800 8,963 → 14,412 798 → 1,323
mattermost/mattermost (af4c3cf) 114,068 → 148,596 51,719 → 85,820 2,395 → 10,254
etcd-io/etcd (64f26db) 16,323 → 22,154 4,657 → 10,381 175 → 482
  • harbor now matches indexing src/ alone.
  • mattermost goes past the server/-only index, since server/public is picked up as its own module.
  • etcd's root-plus-sibling-modules layout is covered too: import-resolved calls from etcdutl/ into server/ go from 0 to 58.

Edges removed: 1 in etcd, 56 in harbor, 20 in mattermost. For example, &traffic.Watch{} used to link to a same-named method and now links to the Watch struct, and mattermost's mlog.Error calls used to land on a stub package under tools/…/testdata. A random sample of added edges was all correct.

The build and the new tests plus resolution.test.ts pass locally. Looks good to me. Thanks for picking this up.

@colbymchenry

Copy link
Copy Markdown
Owner

Thanks @danusha2345! Your module discovery landed in #2361: the nearest go.mod for every directory with indexed Go files, with the longest module path winning. On top of it, a name written through a package (store.Manager) now resolves only within that package's directory, and methods are looked up in the package that declares the type. Without those, module discovery alone added 47 self-loops on harbor and about 180 type references that landed on same-named methods on etcd. The EXTRACTION_VERSION bump was dropped because it was already bumped this release. You're credited as a co-author on the commit and in the changelog. Closing this one in favor of #2361.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

3 participants