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