From 14af7e905d85d559b06855cdb5f935d65853d3c8 Mon Sep 17 00:00:00 2001 From: danusha2345 Date: Sat, 3 Oct 2026 18:10:05 +0300 Subject: [PATCH] fix(go): resolve calls inside a Go module whose go.mod is below the project root (#2322) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only `/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 --- CHANGELOG.md | 3 + __tests__/go-nested-module.test.ts | 233 +++++++++++++++++++++++++++ src/extraction/extraction-version.ts | 2 +- src/resolution/go-module.ts | 37 +++-- src/resolution/import-resolver.ts | 19 ++- src/resolution/index.ts | 51 +++++- src/resolution/name-matcher.ts | 9 +- src/resolution/types.ts | 14 +- 8 files changed, 328 insertions(+), 40 deletions(-) create mode 100644 __tests__/go-nested-module.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 06ca319d98..9dc90eca11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,9 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Fixes + +- In Go, calls between the packages of a module whose `go.mod` sits in a subdirectory instead of the project root, like a `server/` backend next to a `web/` frontend or several modules side by side, now resolve, so a package-qualified call such as `store.New()` or a call through a struct field such as `s.db.CreateItem()` finds its target; re-index after upgrading. Thanks @GoDiao for the report. (#2322) ## [1.6.2] - 2026-10-03 diff --git a/__tests__/go-nested-module.test.ts b/__tests__/go-nested-module.test.ts new file mode 100644 index 0000000000..81f1b22559 --- /dev/null +++ b/__tests__/go-nested-module.test.ts @@ -0,0 +1,233 @@ +/** + * Go modules whose `go.mod` is below the project root (#2322). + * + * A Go backend kept next to a frontend (`server/go.mod`, `web/package.json`) + * or several modules side by side: each module's import paths start with its + * own module path, and an in-module import names a directory under that + * module's root. Only the project-root `go.mod` used to be read, so with the + * module in `svc/` both `store.New()` and `s.db.CreateItem()` lost their + * callers. A root-level `go.mod` resolves exactly as before. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; + +const STORE = `package store + +type Manager struct{} + +func New() *Manager { return &Manager{} } + +func (m *Manager) CreateItem(name string) error { return nil } +`; + +const CLOCK = `package clock + +func Now() int { return 0 } +`; + +// The issue's service, plus a call into a package outside \`internal/\`. +const service = (mod: string) => `package domain + +import ( + "${mod}/internal/store" + "${mod}/pkg/clock" +) + +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) +} + +func Stamp() int { + return clock.Now() +} +`; + +const projects: Array<{ root: string; cg: CodeGraph }> = []; + +async function indexProject(files: Record): Promise { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-go-nested-mod-')); + for (const [rel, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + } + const cg = await CodeGraph.init(root, { index: true }); + projects.push({ root, cg }); + return cg; +} + +afterAll(() => { + for (const { root, cg } of projects) { + cg.close(); + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +/** `file::qualifiedName` of every symbol the function `name` in `file` calls. */ +function callTargets(cg: CodeGraph, file: string, name: string): string[] { + const fn = cg.getNodesInFile(file).find((n) => n.name === name); + expect(fn, `${name} in ${file}`).toBeDefined(); + return cg + .getOutgoingEdges(fn!.id) + .filter((e) => e.kind === 'calls') + .map((e) => cg.getNode(e.target)!) + .map((n) => `${n.filePath.replace(/\\/g, '/')}::${n.qualifiedName}`) + .sort(); +} + +describe('Go module in a subdirectory (#2322)', () => { + let cg: CodeGraph; + beforeAll(async () => { + cg = await indexProject({ + 'svc/go.mod': 'module example.com/app/svc\n\ngo 1.22\n', + 'svc/internal/store/store.go': STORE, + 'svc/pkg/clock/clock.go': CLOCK, + 'svc/internal/domain/service.go': service('example.com/app/svc'), + 'web/package.json': '{ "name": "web" }\n', + }); + }); + + it('resolves a package-qualified call into the module', () => { + expect(callTargets(cg, 'svc/internal/domain/service.go', 'NewService')).toEqual([ + 'svc/internal/store/store.go::New', + ]); + expect(callTargets(cg, 'svc/internal/domain/service.go', 'Stamp')).toEqual([ + 'svc/pkg/clock/clock.go::Now', + ]); + }); + + it('resolves a call through a struct field typed with a package of the module', () => { + expect(callTargets(cg, 'svc/internal/domain/service.go', 'AddItem')).toEqual([ + 'svc/internal/store/store.go::Manager::CreateItem', + ]); + }); +}); + +describe('Go modules side by side (#2322)', () => { + let cg: CodeGraph; + beforeAll(async () => { + cg = await indexProject({ + 'server/go.mod': 'module example.com/server\n\ngo 1.22\n', + 'server/api/api.go': 'package api\n\nfunc Start() int { return 1 }\n', + 'server/cmd/main.go': `package main + +import ( + "example.com/server/api" + "example.com/tools/lint" +) + +type App struct { + linter *lint.Linter +} + +func main() { + api.Start() + lint.Run() +} + +func (a *App) check() { + a.linter.Check() +} +`, + 'server/cmd/ext.go': `package main + +import ( + "example.com/toolsx/api" +) + +func external() { + api.Start() +} +`, + 'tools/go.mod': 'module example.com/tools\n\ngo 1.22\n', + 'tools/api/api.go': 'package api\n\nfunc Start() int { return 2 }\n', + 'tools/lint/lint.go': `package lint + +type Linter struct{} + +func Run() {} + +func (l *Linter) Check() {} +`, + 'tools/gen/gen.go': `package main + +import "example.com/tools/api" + +func generate() { + api.Start() +} +`, + }); + }); + + it("resolves each module's import into that module's own package", () => { + expect(callTargets(cg, 'tools/gen/gen.go', 'generate')).toEqual(['tools/api/api.go::Start']); + expect(callTargets(cg, 'server/cmd/main.go', 'main')).toEqual([ + 'server/api/api.go::Start', + 'tools/lint/lint.go::Run', + ]); + }); + + it('follows a struct field typed with the other module\'s package', () => { + expect(callTargets(cg, 'server/cmd/main.go', 'check')).toEqual(['tools/lint/lint.go::Linter::Check']); + }); + + it('leaves an import that only shares a prefix with a module path unresolved', () => { + expect(callTargets(cg, 'server/cmd/ext.go', 'external')).toEqual([]); + }); +}); + +describe('Two Go modules declaring the same path (#2322)', () => { + let cg: CodeGraph; + beforeAll(async () => { + const files: Record = {}; + for (const copy of ['v1', 'v2']) { + files[`${copy}/go.mod`] = 'module example.com/app/svc\n\ngo 1.22\n'; + files[`${copy}/internal/store/store.go`] = STORE; + files[`${copy}/pkg/clock/clock.go`] = CLOCK; + files[`${copy}/internal/domain/service.go`] = service('example.com/app/svc'); + } + cg = await indexProject(files); + }); + + it("resolves each copy's imports into its own module", () => { + for (const copy of ['v1', 'v2']) { + expect(callTargets(cg, `${copy}/internal/domain/service.go`, 'NewService')).toEqual([ + `${copy}/internal/store/store.go::New`, + ]); + expect(callTargets(cg, `${copy}/internal/domain/service.go`, 'Stamp')).toEqual([ + `${copy}/pkg/clock/clock.go::Now`, + ]); + } + }); +}); + +describe('Go module at the project root', () => { + let cg: CodeGraph; + beforeAll(async () => { + cg = await indexProject({ + 'go.mod': 'module example.com/app/svc\n\ngo 1.22\n', + 'internal/store/store.go': STORE, + 'pkg/clock/clock.go': CLOCK, + 'internal/domain/service.go': service('example.com/app/svc'), + }); + }); + + it('resolves exactly as before', () => { + expect(callTargets(cg, 'internal/domain/service.go', 'NewService')).toEqual(['internal/store/store.go::New']); + expect(callTargets(cg, 'internal/domain/service.go', 'Stamp')).toEqual(['pkg/clock/clock.go::Now']); + expect(callTargets(cg, 'internal/domain/service.go', 'AddItem')).toEqual([ + 'internal/store/store.go::Manager::CreateItem', + ]); + }); +}); diff --git a/src/extraction/extraction-version.ts b/src/extraction/extraction-version.ts index 3691e95f0a..e41b5f6149 100644 --- a/src/extraction/extraction-version.ts +++ b/src/extraction/extraction-version.ts @@ -21,4 +21,4 @@ * turns the re-index hint into noise — keep it honest (see CLAUDE.md, "Honesty * in the product is load-bearing"). */ -export const EXTRACTION_VERSION = 27; +export const EXTRACTION_VERSION = 28; diff --git a/src/resolution/go-module.ts b/src/resolution/go-module.ts index 03f6b9b237..78c80d725d 100644 --- a/src/resolution/go-module.ts +++ b/src/resolution/go-module.ts @@ -20,15 +20,13 @@ export interface GoModule { } /** - * Read the `go.mod` file at the project root and extract the module path. - * Returns `null` if no `go.mod` exists or it has no `module` directive. - * - * Limitation: only the project-root `go.mod` is read. Nested `go.mod` files - * (Go workspaces, monorepos with multiple modules) are not yet resolved — - * a follow-up if a real repro shows up. + * Read the `go.mod` file in `moduleDir` and extract the module path. + * Returns `null` if no `go.mod` exists there or it has no `module` directive. + * The resolver calls it for every directory between a Go file and the project + * root, so a module whose `go.mod` sits below the root resolves too (#2322). */ -export function loadGoModule(projectRoot: string): GoModule | null { - const goModPath = path.join(projectRoot, 'go.mod'); +export function loadGoModule(moduleDir: string): GoModule | null { + const goModPath = path.join(moduleDir, 'go.mod'); let content: string; try { content = fs.readFileSync(goModPath, 'utf-8'); @@ -43,5 +41,26 @@ export function loadGoModule(projectRoot: string): GoModule | null { // Strip optional quoting around the module path. const modulePath = match[1]!.replace(/^["']|["']$/g, ''); if (!modulePath) return null; - return { modulePath, rootDir: projectRoot }; + return { modulePath, rootDir: moduleDir }; +} + +/** + * The module an import path belongs to: the one whose module path equals it or + * is a `/`-bounded prefix of it. Nested modules (`example.com/app` and + * `example.com/app/tools`) take the longest module path, as Go does; two + * modules declaring the same path prefer `own`, the importing file's module. + * `null` for the standard library and third-party modules. + */ +export function findGoModuleForImport( + importPath: string, + modules: readonly GoModule[], + own?: GoModule | null +): GoModule | null { + let best: GoModule | null = null; + for (const mod of modules) { + if (importPath !== mod.modulePath && !importPath.startsWith(`${mod.modulePath}/`)) continue; + const length = best ? best.modulePath.length : -1; + if (mod.modulePath.length > length || (mod.modulePath.length === length && mod === own)) best = mod; + } + return best; } diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts index 7fa9912e23..a98c89c7fd 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -465,8 +465,7 @@ export function isExternalImport( // In-module imports look like `/sub/pkg` — local to // this project. Without the module-path check we'd flag every // cross-package call in a Go monorepo as external (issue #388). - const mod = context?.getGoModule?.(); - if (mod && (importPath === mod.modulePath || importPath.startsWith(mod.modulePath + '/'))) { + if (context?.getGoModuleForImport?.(importPath)) { return false; } // `internal/` packages stay local even when go.mod is missing — @@ -2481,9 +2480,6 @@ function resolveGoCrossPackageReference( imports: ImportMapping[], context: ResolutionContext ): ResolvedRef | null { - const mod = context.getGoModule?.(); - if (!mod) return null; - // Qualified call: receiver before `.`, member after. A bare reference // (no dot) is a same-file/in-package call — handled elsewhere. const dotIdx = ref.referenceName.indexOf('.'); @@ -2494,13 +2490,16 @@ function resolveGoCrossPackageReference( for (const imp of imports) { if (imp.localName !== receiver) continue; - // Only in-module imports map to a known directory. - if (imp.source !== mod.modulePath && !imp.source.startsWith(mod.modulePath + '/')) { - continue; - } - const pkgDir = imp.source === mod.modulePath + // Only in-module imports map to a known directory: the module's own + // directory (the project root, or wherever its go.mod sits — #2322) + // followed by the rest of the import path. + const mod = context.getGoModuleForImport?.(imp.source, ref.filePath); + if (!mod) continue; + const modDir = path.relative(context.getProjectRoot(), mod.rootDir).replace(/\\/g, '/'); + const subDir = imp.source === mod.modulePath ? '' : imp.source.substring(mod.modulePath.length + 1); + const pkgDir = modDir && subDir ? `${modDir}/${subDir}` : modDir || subDir; // Look up the member by name and pick the candidate whose file lives // directly in the package directory. Match the immediate parent dir diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 1eaf14b145..c8a8354a83 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -34,7 +34,7 @@ import { synthesizeCallbackEdges } from './callback-synthesizer'; import { createYielder, type MaybeYield } from './cooperative-yield'; import { MAX_SOURCE_FILE_SIZE_BYTES } from '../file-limits'; import { loadProjectAliases, type AliasMap } from './path-aliases'; -import { loadGoModule, type GoModule } from './go-module'; +import { findGoModuleForImport, loadGoModule, type GoModule } from './go-module'; import { loadWorkspacePackages, type WorkspacePackages } from './workspace-packages'; import { logDebug } from '../errors'; import { lexicalPathWithinRoot } from '../utils'; @@ -309,8 +309,12 @@ export class ReferenceResolver { private projectAliases: AliasMap | null | undefined = undefined; // Per directory: the aliases of the nearest non-root tsconfig declaring `paths`. private dirAliases = new Map(); - // go.mod module path. Same lazy/immutable convention as projectAliases. - private goModule: GoModule | null | undefined = undefined; + // Per directory: the module of the nearest go.mod at or above it, up to the + // project root ('' = the root). Same lazy/immutable convention as dirAliases. + private goModuleByDir = new Map(); + // Every module owning an indexed .go file, plus the root one. Depends on the + // file set, like knownFiles, so clearCaches drops it. + private goModules: GoModule[] | null = null; // Monorepo workspace member packages. Same lazy/immutable convention. private workspacePackages: WorkspacePackages | null | undefined = undefined; @@ -449,6 +453,7 @@ export class ReferenceResolver { this.knownNames = null; this.knownLowerNames = null; this.knownFiles = null; + this.goModules = null; this.cachesWarmed = false; // The import-resolver's and name-matcher's per-context memos assume the // same stable window as the caches above — drop them together. @@ -461,6 +466,22 @@ export class ReferenceResolver { } } + /** The module of the nearest `go.mod` at or above project-relative `dir`, up to the project root. */ + private nearestGoModule(dir: string): GoModule | null { + const walked: string[] = []; + let found: GoModule | null | undefined; + for (;;) { + found = this.goModuleByDir.get(dir); + if (found !== undefined) break; + walked.push(dir); + found = loadGoModule(path.join(this.projectRoot, dir)); + if (found || dir === '') break; + dir = goDirOf(dir); + } + for (const d of walked) this.goModuleByDir.set(d, found); + return found; + } + /** `readFile` through the LRU content cache (null = read failed, also cached). */ private readFileCached(filePath: string): string | null { if (this.fileCache.has(filePath)) { @@ -803,11 +824,21 @@ export class ReferenceResolver { return found; }, - getGoModule: () => { - if (this.goModule === undefined) { - this.goModule = loadGoModule(this.projectRoot); + getGoModuleForImport: (importPath: string, fromFile?: string) => { + if (this.goModules === null) { + const dirs = new Set(['']); + for (const file of this.queries.getAllFilePaths()) { + if (file.endsWith('.go')) dirs.add(goDirOf(file)); + } + const modules = new Set(); + for (const dir of dirs) { + const mod = this.nearestGoModule(dir); + if (mod) modules.add(mod); + } + this.goModules = [...modules]; } - return this.goModule; + const own = fromFile === undefined ? null : this.nearestGoModule(goDirOf(fromFile)); + return findGoModuleForImport(importPath, this.goModules, own); }, getWorkspacePackages: () => { @@ -3074,6 +3105,12 @@ export class ReferenceResolver { } } +/** The project-relative directory holding `p` ('' for the project root). */ +function goDirOf(p: string): string { + const dir = path.posix.dirname(p.replace(/\\/g, '/')); + return dir === '.' || dir === '/' ? '' : dir; +} + /** * Create a reference resolver instance */ diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index e0b4ad28b8..256d62a2ff 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -1822,9 +1822,8 @@ function isGoExternalQualified(ref: UnresolvedRef, context: ResolutionContext): if (before) { const imported = context.getImportMappings(ref.filePath, 'go').find((m) => m.localName === before); if (imported) { - const mod = context.getGoModule?.(); const local = imported.source.startsWith('.') || imported.source.includes('/internal/') || - (mod !== undefined && mod !== null && (imported.source === mod.modulePath || imported.source.startsWith(`${mod.modulePath}/`))); + !!context.getGoModuleForImport?.(imported.source); external = !local; } } @@ -8282,14 +8281,10 @@ function matchGoFieldChainCall( // fabrication this matcher exists to prevent (#1276). if (rawType.includes('.')) { const pkg = rawType.split('.')[0]!; - const mod = context.getGoModule?.(); const imp = context .getImportMappings(s.filePath, 'go') .find((i) => i.localName === pkg); - const inModule = - !!mod && - !!imp && - (imp.source === mod.modulePath || imp.source.startsWith(mod.modulePath + '/')); + const inModule = !!imp && !!context.getGoModuleForImport?.(imp.source); if (!inModule) continue; } // Unexported (lowercase) types are idiomatic Go and stay eligible — diff --git a/src/resolution/types.ts b/src/resolution/types.ts index 1b1f11226a..5a833b662e 100644 --- a/src/resolution/types.ts +++ b/src/resolution/types.ts @@ -187,13 +187,15 @@ export interface ResolutionContext { */ getNearestAliases?(fromFile: string): import('./path-aliases').AliasMap | null; /** - * Go module info from `go.mod` at the project root. Returns `null` - * when the project has no `go.mod` (non-Go projects, pre-modules - * Go code, or projects whose modules live in subdirectories). Used - * by the Go branch of import resolution to distinguish in-module - * cross-package imports from third-party packages. + * The project's Go module that an import path belongs to — one whose + * `go.mod` is at the project root or in any directory above an indexed + * `.go` file (#2322) — or `null` for the standard library, third-party + * modules and projects without a `go.mod`. `fromFile` (the importing file) + * breaks a tie between two modules declaring the same path. Used by the Go + * branch of import resolution to distinguish in-project cross-package + * imports from third-party packages. */ - getGoModule?(): import('./go-module').GoModule | null; + getGoModuleForImport?(importPath: string, fromFile?: string): import('./go-module').GoModule | null; /** * Monorepo workspace member packages, keyed by declared package name. * Returns `null` for single-package repos (no `workspaces` field).