diff --git a/CHANGELOG.md b/CHANGELOG.md index bad7889d0..cc671ae62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In COBOL, asking `codegraph_explore` (or `codegraph explore` and `codegraph context`) about a copybook by name, such as `CVACT01Y` or a member brought in with `EXEC SQL INCLUDE`, now answers with the copybook's own source when it is indexed and every `COPY` or `EXEC SQL INCLUDE` statement that includes it, and says so when the copybook's source isn't part of the project. Before, it answered "No relevant code found", or showed an unrelated program whose name was a letter or two away. Thanks @popolusiak for the report. (#2342) - In Dart, reading a getter such as `box.area` or `status.label` now counts as calling it, so `codegraph callers` and impact list the code that reads it — whether the getter is declared on the class, inherited, or added by an extension — and a method an extension adds to an enum, like `shape.shout()`, now links as well. A plain field, a getter of a type from outside your project, or a value whose type isn't written down still links nothing. Re-index Dart projects after upgrading. Thanks @brookly255-lv for the report. (#2338) - In Dart, a type is now linked from more of the places it is written, not only from parameters and return types: the type an `extension … on` targets, a field's type, a top-level variable's type, generic arguments such as `Future.value(null)` or Riverpod's `final reportProvider = Family()`, and a local variable's type, a cast or a type check inside a function. So `callers` and impact for a model class now reach the extensions, models and providers that use it. Thanks @mg-mg-mg for the report. (#2327) +- In Go, calls through a method receiver or parameter of an unexported type, the usual shape of gRPC and HTTP handlers (`s.service.AddItem()` inside `func (s *server) Create()`), now resolve, and always within that type's own package: another package's `server` with a same-named method no longer takes the call, and a method the type gets from a struct or interface it embeds is found too. Re-index Go projects after upgrading. Thanks @GoDiao for the report and the fix. (#2323) +- In Go, calls into a module whose `go.mod` is not at the project root now resolve, whether it is a `server/` backend next to a `web/` frontend or one of several modules side by side as in etcd, so calls like `store.New()` and `s.db.CreateItem()` find their targets instead of being treated as calls into a third-party package. A name written through a package, like a `job.OPCommand` result type or a field of type `artifact.Manager`, now links to that package's symbol rather than a same-named one elsewhere, which also corrects links in projects with a single `go.mod`. Re-index Go projects after upgrading. Thanks @GoDiao for the report and @danusha2345 for the fix. (#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 000000000..20c4b90f4 --- /dev/null +++ b/__tests__/go-nested-module.test.ts @@ -0,0 +1,331 @@ +/** + * Go modules whose `go.mod` is not at the project root (#2322). + * + * A Go backend kept next to a frontend (`server/go.mod`, `web/package.json`), + * or several modules side by side — etcd's root module beside `server/go.mod` + * and `client/v3/go.mod`: each module's import paths start with its own + * module path, and name 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. An import path + * belongs to the module declaring the longest prefix of it, and a name + * written through a package is that package's — never a same-named symbol of + * another package. 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 `kind` target of the symbol `name` in `file`. */ +function targets(cg: CodeGraph, file: string, name: string, kind = 'calls'): 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 === kind) + .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(targets(cg, 'svc/internal/domain/service.go', 'NewService')).toEqual([ + 'svc/internal/store/store.go::New', + ]); + expect(targets(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(targets(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({ + // etcd's shape: a root module, and a sibling whose path is not under it. + 'go.mod': 'module example.com/etcd/v3\n\ngo 1.22\n', + 'etcdutl/main.go': `package main + +import "example.com/etcd/server/v3/storage/wal" + +func main() { + wal.OpenForRead("dir") +} +`, + 'server/go.mod': 'module example.com/etcd/server/v3\n\ngo 1.22\n', + 'server/storage/wal/wal.go': 'package wal\n\nfunc OpenForRead(dir string) error { return nil }\n', + 'server/api/api.go': 'package api\n\nfunc Start() int { return 1 }\n', + 'server/cmd/main.go': `package main + +import ( + "example.com/etcd/server/v3/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 an import of a sibling module whose path is not under the root module', () => { + expect(targets(cg, 'etcdutl/main.go', 'main')).toEqual(['server/storage/wal/wal.go::OpenForRead']); + }); + + it("resolves each module's import into that module's own package", () => { + expect(targets(cg, 'tools/gen/gen.go', 'generate')).toEqual(['tools/api/api.go::Start']); + expect(targets(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(targets(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(targets(cg, 'server/cmd/ext.go', 'external')).toEqual([]); + }); +}); + +describe('Names written through a package of a nested module (#2322)', () => { + let cg: CodeGraph; + beforeAll(async () => { + cg = await indexProject({ + 'src/go.mod': 'module example.com/harbor/src\n\ngo 1.22\n', + 'src/jobservice/job/op.go': 'package job\n\ntype OPCommand string\n', + // A result type spelled like the method that returns it. + 'src/jobservice/impl/context.go': `package impl + +import "example.com/harbor/src/jobservice/job" + +type Context struct{} + +func (c *Context) OPCommand() (job.OPCommand, bool) { + return "", false +} +`, + 'src/pkg/artifact/manager.go': `package artifact + +type Manager interface { + Count() int64 +} +`, + // A caching wrapper delegating to the same-named type of another package. + 'src/pkg/cached/redis/manager.go': `package redis + +import "example.com/harbor/src/pkg/artifact" + +type Manager struct { + delegator artifact.Manager +} + +func (m *Manager) Count() int64 { + return m.delegator.Count() +} +`, + // A test fixture claiming the real module's path. The go tool ignores + // testdata, so it never answers another module's import of that path. + 'internal/testdata/stub/go.mod': 'module example.com/harbor/src\n\ngo 1.22\n', + 'internal/testdata/stub/pkg/artifact/manager.go': `package artifact + +type Manager interface { + Count() int64 +} +`, + 'tools/go.mod': 'module example.com/harbor/tools\n\ngo 1.22\n', + 'tools/report/report.go': `package report + +import "example.com/harbor/src/pkg/artifact" + +type Reporter struct { + mgr artifact.Manager +} + +func (r *Reporter) Total() int64 { + return r.mgr.Count() +} +`, + }); + }); + + it('a type reference lands on the named package’s type, not a same-named method', () => { + expect(targets(cg, 'src/jobservice/impl/context.go', 'OPCommand', 'references')).toEqual([ + 'src/jobservice/job/op.go::OPCommand', + ]); + }); + + it('a field typed with another package’s same-named type reaches that type, not the caller itself', () => { + expect(targets(cg, 'src/pkg/cached/redis/manager.go', 'Count')).toEqual([ + 'src/pkg/artifact/manager.go::Manager::Count', + ]); + }); + + it('a module under testdata never answers for the module it imitates', () => { + expect(targets(cg, 'tools/report/report.go', 'Total')).toEqual([ + 'src/pkg/artifact/manager.go::Manager::Count', + ]); + }); +}); + +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(targets(cg, `${copy}/internal/domain/service.go`, 'NewService')).toEqual([ + `${copy}/internal/store/store.go::New`, + ]); + expect(targets(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(targets(cg, 'internal/domain/service.go', 'NewService')).toEqual(['internal/store/store.go::New']); + expect(targets(cg, 'internal/domain/service.go', 'Stamp')).toEqual(['pkg/clock/clock.go::Now']); + expect(targets(cg, 'internal/domain/service.go', 'AddItem')).toEqual([ + 'internal/store/store.go::Manager::CreateItem', + ]); + }); +}); diff --git a/__tests__/go-unexported-receiver.test.ts b/__tests__/go-unexported-receiver.test.ts new file mode 100644 index 000000000..dca097d92 --- /dev/null +++ b/__tests__/go-unexported-receiver.test.ts @@ -0,0 +1,140 @@ +/** + * Go calls through a method receiver of an unexported type (#2323). + * + * `func (s *server) Create()` is the idiomatic shape of gRPC and HTTP + * handlers, but only a PascalCase typed parameter used to give a receiver its + * type, so `s.service.AddItem()` — and every other call through `s` — lost its + * edge. Unexported names repeat across packages (each package may have its + * own `server`), so a receiver's type is its own package's: a same-named type + * elsewhere, with the same method or the same field, is never the target. + */ +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'; + +let root = ''; +let cg: CodeGraph; + +beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-go-unexported-recv-')); + const files: Record = { + 'go.mod': 'module example.com/app\n\ngo 1.22\n', + 'internal/domain/service.go': `package domain + +type Service struct{} + +func (s *Service) AddItem(name string) error { return nil } + +func (s *Service) Count() int { return 0 } +`, + // The issue's handler, plus calls on the receiver itself, through a type + // it embeds and through a field of an unexported type. + 'internal/handlers/server.go': `package handlers + +import "example.com/app/internal/domain" + +type server struct { + BaseAPI + service *domain.Service + store *store +} + +func (s *server) Create(name string) error { + s.store.Put(name) + s.Close() + s.SendError() + return s.service.AddItem(name) +} + +type cache[T any] struct { + service *domain.Service +} + +func (c *cache[T]) Size() int { + return c.service.Count() +} +`, + 'internal/handlers/store.go': `package handlers + +type store struct{} + +func (st *store) Put(name string) {} + +func (s *server) Close() {} +`, + 'internal/handlers/base.go': `package handlers + +type BaseAPI struct{} + +func (b *BaseAPI) SendError() {} +`, + // Another package declaring the same unexported names: never a target. + 'internal/admin/server.go': `package admin + +type server struct { + BaseAPI + store *store +} + +type store struct{} + +func (st *store) Put(name string) {} + +func (s *server) Close() {} + +type BaseAPI struct{} + +func (b *BaseAPI) SendError() {} +`, + }; + 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); + } + cg = await CodeGraph.init(root, { index: true }); +}); + +afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); +}); + +/** `file::qualifiedName` of every symbol the function `name` in `file` calls. */ +function callTargets(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 calls through an unexported method receiver (#2323)', () => { + it('resolves a call through a field of the receiver', () => { + expect(callTargets('internal/handlers/server.go', 'Create')).toContain( + 'internal/domain/service.go::Service::AddItem' + ); + }); + + it('resolves a generic receiver past its type parameters', () => { + expect(callTargets('internal/handlers/server.go', 'Size')).toEqual([ + 'internal/domain/service.go::Service::Count', + ]); + }); + + it("resolves every call within the receiver's own package, never a same-named type's in another", () => { + expect(callTargets('internal/handlers/server.go', 'Create')).toEqual([ + 'internal/domain/service.go::Service::AddItem', + 'internal/handlers/base.go::BaseAPI::SendError', + 'internal/handlers/store.go::server::Close', + 'internal/handlers/store.go::store::Put', + ]); + for (const decoy of cg.getNodesInFile('internal/admin/server.go').filter((n) => n.kind === 'method')) { + expect(cg.getIncomingEdges(decoy.id).filter((e) => e.kind === 'calls'), decoy.qualifiedName).toEqual([]); + } + }); +}); diff --git a/src/resolution/go-module.ts b/src/resolution/go-module.ts index 03f6b9b23..73ff77de6 100644 --- a/src/resolution/go-module.ts +++ b/src/resolution/go-module.ts @@ -7,6 +7,12 @@ * — `github.com/example/myproject/pkga` — as a third-party package, so * resolution falls through to name-matching with path proximity and returns * a tiny fraction of the real call sites. See issue #388. + * + * A project can hold several modules — a Go backend in `server/` next to a + * frontend, or etcd's root module beside `server/go.mod` and + * `client/v3/go.mod` — so the resolver reads the `go.mod` nearest every + * directory with indexed Go files and maps an import path to the module + * that declares the longest prefix of it (#2322). */ import * as fs from 'fs'; @@ -20,15 +26,11 @@ 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. */ -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 +45,37 @@ 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 own && !modules.includes(own) ? [...modules, own] : 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; +} + +/** + * The project-relative directory (`/`-separated, `.` for the project root) + * of the package `importPath` names inside module `mod`: the module's own + * directory followed by the rest of the import path. + */ +export function goModulePackageDir(importPath: string, mod: GoModule, projectRoot: string): string { + const modDir = path.relative(projectRoot, mod.rootDir).split(path.sep).join('/'); + const rest = importPath === mod.modulePath ? '' : importPath.slice(mod.modulePath.length + 1); + return path.posix.join(modDir, rest); } diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts index 7fa9912e2..30bd4137b 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -18,6 +18,7 @@ import { resolveObjectLiteralBinding, localReceiverTypePatterns, normalizeInferredTypeName, + goRefQualifier, } from './name-matcher'; /** @@ -464,9 +465,10 @@ 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 + '/'))) { + // cross-package call in a Go monorepo as external (issue #388) — and, + // for a module whose go.mod is below the root or beside other modules, + // every import of it (#2322). + if (context?.getGoPackageDir?.(importPath) != null) { return false; } // `internal/` packages stay local even when go.mod is missing — @@ -2470,57 +2472,63 @@ function resolveJavaImportedReference( /** * Resolve a Go cross-package qualified reference (`pkga.FuncX`) by matching - * the package alias against an in-module import, stripping the module prefix - * to a project-relative directory, and locating the exported symbol in any - * `.go` file under that directory. Returns `null` for stdlib / third-party - * imports (no `go.mod`-relative match) so the rest of `resolveViaImport` - * can still try the file-based path. + * the package alias against an in-project import, mapping the import to the + * package's project directory, and locating the exported symbol in a `.go` + * file directly in that directory. Returns `null` for stdlib / third-party + * imports (no project module declares them) so the rest of + * `resolveViaImport` can still try the file-based path. */ function resolveGoCrossPackageReference( ref: UnresolvedRef, 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. + // (no dot) is a same-package name — unless the source spells a package in + // front of it: `store.Manager` in a field or a signature, which the index + // keeps as `Manager` (#2322). const dotIdx = ref.referenceName.indexOf('.'); - if (dotIdx <= 0) return null; + if (dotIdx <= 0) { + const imp = goRefQualifier(ref, context); + return imp ? findGoPackageMember(ref.referenceName, imp, ref, context) : null; + } const receiver = ref.referenceName.substring(0, dotIdx); const memberName = ref.referenceName.substring(dotIdx + 1); if (!memberName) return null; 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 - ? '' - : imp.source.substring(mod.modulePath.length + 1); + const found = findGoPackageMember(memberName, imp, ref, context); + if (found) return found; + } + return null; +} - // Look up the member by name and pick the candidate whose file lives - // directly in the package directory. Match the immediate parent dir - // exactly so a call to `pkga.FuncX` doesn't accidentally land on a - // `FuncX` declared in `pkga/subpkg/`. - const candidates = context.getNodesByName(memberName); - for (const node of candidates) { - if (node.language !== 'go') continue; - if (!node.isExported) continue; - const fp = node.filePath.replace(/\\/g, '/'); - const lastSlash = fp.lastIndexOf('/'); - const fileDir = lastSlash >= 0 ? fp.substring(0, lastSlash) : ''; - if (fileDir === pkgDir) { - return { - original: ref, - targetNodeId: node.id, - confidence: 0.9, - resolvedBy: 'import', - }; - } +/** + * The exported `memberName` of the project package `imp` names, looked up in + * the package's directory: its module's directory followed by the rest of + * the import path (#2322). Matched on the immediate directory so `pkga.FuncX` + * never lands on a `FuncX` declared in `pkga/subpkg/`. Null when the import + * is no project package. + */ +function findGoPackageMember( + memberName: string, + imp: ImportMapping, + ref: UnresolvedRef, + context: ResolutionContext +): ResolvedRef | null { + const pkgDir = context.getGoPackageDir?.(imp.source, ref.filePath); + if (pkgDir == null) return null; + for (const node of context.getNodesByName(memberName)) { + if (node.language !== 'go') continue; + if (!node.isExported) continue; + if (path.posix.dirname(node.filePath.replace(/\\/g, '/')) === pkgDir) { + return { + original: ref, + targetNodeId: node.id, + confidence: 0.9, + resolvedBy: 'import', + }; } } return null; diff --git a/src/resolution/index.ts b/src/resolution/index.ts index fa3de7988..b3303c9e2 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -35,7 +35,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, goModulePackageDir, loadGoModule, type GoModule } from './go-module'; import { loadWorkspacePackages, type WorkspacePackages } from './workspace-packages'; import { logDebug } from '../errors'; import { lexicalPathWithinRoot } from '../utils'; @@ -310,8 +310,17 @@ 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 project-relative directory ('' = the root): the module of the nearest + // go.mod at or above it, up to the project root. Same lazy/immutable + // convention as dirAliases. + private goModuleByDir = new Map(); + // The project's Go modules: the root one plus every module that owns an + // indexed .go file. Depends on the file set, like knownFiles, so + // clearCaches drops it. + private goModules: GoModule[] | null = null; + // getGoPackageDir answers, keyed by import path and the importing file's + // module. Derived from goModules, so dropped with it. + private goPackageDirs = new Map(); // Monorepo workspace member packages. Same lazy/immutable convention. private workspacePackages: WorkspacePackages | null | undefined = undefined; @@ -450,6 +459,8 @@ export class ReferenceResolver { this.knownNames = null; this.knownLowerNames = null; this.knownFiles = null; + this.goModules = null; + this.goPackageDirs.clear(); 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. @@ -463,6 +474,49 @@ export class ReferenceResolver { } } + /** + * The module of the nearest `go.mod` at or above project-relative `dir` + * ('' = the root), never above the project root (#2322). Each directory + * is read at most once: every directory the walk passes is memoized. + */ + 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; + } + + /** + * The project's Go modules: the root `go.mod`'s, and the nearest `go.mod` + * of every directory holding an indexed `.go` file — so an ignored or + * vendored tree, which holds no indexed file, adds none, and nothing walks + * the disk. The go tool ignores `testdata/` and `_`/`.`-prefixed + * directories, so a module there serves only its own files (#2322). + */ + private getGoModules(): GoModule[] { + if (this.goModules) return this.goModules; + const dirs = new Set(['']); + for (const file of this.knownFiles ?? 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); + const modDir = mod ? path.relative(this.projectRoot, mod.rootDir).split(path.sep).join('/') : ''; + if (mod && !/(?:^|\/)(?:testdata|[_.][^/]*)(?:\/|$)/.test(modDir)) modules.add(mod); + } + this.goModules = [...modules]; + return this.goModules; + } + /** `readFile` through the LRU content cache (null = read failed, also cached). */ private readFileCached(filePath: string): string | null { if (this.fileCache.has(filePath)) { @@ -805,11 +859,15 @@ export class ReferenceResolver { return found; }, - getGoModule: () => { - if (this.goModule === undefined) { - this.goModule = loadGoModule(this.projectRoot); - } - return this.goModule; + getGoPackageDir: (importPath: string, fromFile?: string) => { + const own = fromFile === undefined ? null : this.nearestGoModule(goDirOf(fromFile)); + const key = `${importPath}\0${own?.rootDir ?? ''}`; + const hit = this.goPackageDirs.get(key); + if (hit !== undefined) return hit; + const mod = findGoModuleForImport(importPath, this.getGoModules(), own); + const dir = mod ? goModulePackageDir(importPath, mod, this.projectRoot) : null; + this.goPackageDirs.set(key, dir); + return dir; }, getWorkspacePackages: () => { @@ -3090,6 +3148,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 d0d8de76f..87689060a 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -300,8 +300,12 @@ function matchMemberFunctionRef(ref: UnresolvedRef, context: ResolutionContext): } if (ref.language === 'go') { if (receiver.includes('.')) return matchGoFieldChainCall(receiver, member, ref, context); - const type = inferLocalReceiverType(receiver, ref, context); - if (type) return resolveMethodOnType(type, member, ref, context, 0.9, 'function-ref'); + const decl: { raw?: string } = {}; + const type = inferLocalReceiverType(receiver, ref, context, decl); + if (type) { + return resolveMethodOnType(type, member, ref, context, 0.9, 'function-ref', + goDeclaredTypePackage(decl.raw, ref.filePath, context)); + } const types = context.getNodesByName(receiver).filter(n => n.language === 'go' && (n.kind === 'struct' || n.kind === 'interface')); if (types.length) return types.length === 1 ? resolveMethodOnType(receiver, member, ref, context, 0.9, 'function-ref') : null; } else { @@ -1864,40 +1868,61 @@ function rustModuleDir(filePath: string): string { return path.posix.join(dir, base.replace(/\.rs$/, '')); } -const GO_EXTERNAL_QUALIFIED = new WeakMap>(); +const GO_QUALIFIERS = new WeakMap>(); /** - * Whether a Go reference is written through an imported package from outside - * the module — `context.Context`, `fmt.Errorf`, a third-party `gin.H` — read - * from its line, since the index keeps only the name. + * The import a Go reference is written through — `context` in + * `context.Context`, `store` in `store.Manager` — read from its line, since + * the index keeps only the name. Undefined for a name written bare, or + * through anything that isn't one of the file's imports. */ -function isGoExternalQualified(ref: UnresolvedRef, context: ResolutionContext): boolean { - if (ref.referenceKind === 'imports') return false; +export function goRefQualifier(ref: UnresolvedRef, context: ResolutionContext): ImportMapping | undefined { + if (ref.referenceKind === 'imports') return undefined; const name = ref.referenceName.split('.').pop()!; - if (!/^[A-Za-z_]\w*$/.test(name)) return false; - let memo = GO_EXTERNAL_QUALIFIED.get(context); - if (!memo) GO_EXTERNAL_QUALIFIED.set(context, (memo = new Map())); + if (!/^[A-Za-z_]\w*$/.test(name)) return undefined; + let memo = GO_QUALIFIERS.get(context); + if (!memo) GO_QUALIFIERS.set(context, (memo = new Map())); const key = `${ref.filePath}\0${ref.line}\0${ref.column}\0${ref.referenceName}`; const hit = memo.get(key); - if (hit !== undefined) return hit; - let external = false; + if (hit !== undefined) return hit ?? undefined; const line = context.getFileLines?.(ref.filePath)?.[ref.line - 1] ?? context.readFile(ref.filePath)?.split(/\r?\n/)[ref.line - 1] ?? ''; const at = Math.max(0, ref.column); // The qualifier right before the name at the reference's column, or the // line's only spelling of the name. const before = line.startsWith(name, at) ? /(?:^|[^\w.])([A-Za-z_]\w*)\.$/.exec(line.slice(0, at))?.[1] : !new RegExp(`(? 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}/`))); - external = !local; - } - } - memo.set(key, external); - return external; + const imported = before ? context.getImportMappings(ref.filePath, 'go').find((m) => m.localName === before) : undefined; + memo.set(key, imported ?? null); + return imported; +} + +/** + * Whether a Go reference is written through an imported package from outside + * the project's modules — `context.Context`, `fmt.Errorf`, a third-party + * `gin.H`. + */ +function isGoExternalQualified(ref: UnresolvedRef, context: ResolutionContext): boolean { + const imported = goRefQualifier(ref, context); + if (!imported) return false; + return !(imported.source.startsWith('.') || imported.source.includes('/internal/') || + context.getGoPackageDir?.(imported.source, ref.filePath) != null); +} + +/** + * Whether `candidate` lives in the project package a Go reference is written + * through, when it is one: `store.Manager` names the `Manager` of the + * directory the `store` import maps to, never a same-named symbol of another + * package (#2322). + */ +function isInGoQualifierPackage(candidate: Node, ref: UnresolvedRef, context: ResolutionContext): boolean { + // A package never holds a method: a call or method value landing on one is + // made through a variable that shadows the import — prometheus's + // `labels := &maxHeap{}`, then `labels.get()`. + if (candidate.kind === 'method' && (ref.referenceKind === 'calls' || ref.referenceKind === 'function_ref')) return true; + const imported = goRefQualifier(ref, context); + if (!imported) return true; + const pkgDir = context.getGoPackageDir?.(imported.source, ref.filePath); + return pkgDir == null || goPackageDir(candidate.filePath) === pkgDir; } const PHP_CLASS_KINDS: ReadonlySet = new Set(['class', 'interface', 'trait', 'enum']); @@ -2163,7 +2188,10 @@ export function isVisibleAcrossFiles(candidate: Node, ref: UnresolvedRef, contex // Go's `context.Context`, `http.Handler`: written through a package from // outside the module, so nothing in it — not even the same file's method // `Stream.Context` (fiber's 85 `context.Context` parameters went there). - if (ref.language === 'go' && candidate.language === 'go' && isGoExternalQualified(ref, context)) return false; + // Through one of the project's packages, only that package's symbol: a + // `job.OPCommand` result type is not the method `Context.OPCommand`. + if (ref.language === 'go' && candidate.language === 'go' && + (isGoExternalQualified(ref, context) || !isInGoQualifierPackage(candidate, ref, context))) return false; if (candidate.filePath === ref.filePath) return true; // A vendored minified bundle's names are mangled: healthchecks' 369 `$(…)` // (jQuery, a global) went to a one-letter helper inside bootstrap-native.min.js. @@ -6253,6 +6281,8 @@ export function resolveMethodOnType( * `dao/converter/` and `service/converter/`), the FQN's * file-path-suffix picks the right one — the disambiguation * signal Java imports carry but the call site doesn't (#314). + * For Go it is the project-relative directory of the package that + * declares `typeName`, when that is known (resolveGoMethodInPackage). */ preferredFqn?: string, /** Recursion guard for the supertype/conformance walk. */ @@ -6284,6 +6314,10 @@ export function resolveMethodOnType( } } } + if (ref.language === 'go' && preferredFqn !== undefined) { + const scoped = resolveGoMethodInPackage(typeName, methodName, preferredFqn, matches, ref, context, confidence, resolvedBy, depth); + if (scoped !== undefined) return scoped; + } if (matches.length === 0) { // Conformance fallback: the method may be defined on a supertype `typeName` // extends, or on a protocol / trait it conforms to (e.g. a Swift protocol- @@ -6314,7 +6348,7 @@ export function resolveMethodOnType( return null; } - if (matches.length > 1 && preferredFqn) { + if (matches.length > 1 && preferredFqn && ref.language !== 'go') { const ext = ref.language === 'kotlin' ? '.kt' : '.java'; const fqnPath = preferredFqn.replace(/\./g, '/') + ext; const chosen = matches.find((m) => { @@ -6876,7 +6910,7 @@ const PATTERN_MEMO_CAP = 8192; * ReferenceResolver.clearCaches calls clearNameMatcherMemos alongside * clearImportResolverMemos. */ -type InferScanState = { hi: number; ansIdx: number; ansType: string | null }; +type InferScanState = { hi: number; ansIdx: number; ansType: string | null; ansRaw?: string }; const INFER_SCAN_STATES = new WeakMap>(); /** Awaited inference caches are scoped to the resolver's stable-source window. @@ -6959,7 +6993,8 @@ export function clearNameMatcherMemos(context: ResolutionContext): void { PY_FIXTURE_TYPES.delete(context); PY_PLUGGED_MODULES.delete(context); SCALA_OBJECT_PACKAGES.delete(context); - GO_EXTERNAL_QUALIFIED.delete(context); + GO_QUALIFIERS.delete(context); + GO_EMBEDS.delete(context); JAVA_FILE_SCOPES.delete(context); JAVA_ANCESTORS.delete(context); SCALA_SUPERS.delete(context); @@ -7078,6 +7113,14 @@ function buildLocalReceiverTypePatterns(language: Language, r: string): RegExp[] return [ new RegExp(`\\b${r}\\b\\s*:=\\s*&?([A-Za-z_][\\w.]*)\\s*{`), // lg := Logger{} / &Logger{} new RegExp(`\\bvar\\s+${r}\\s+\\*?([A-Za-z_][\\w.]*)`), // var lg Logger / *Logger + // A method receiver or parameter of an unexported type — `func (s + // *server)`, `func (c *cache[T])`, `func handle(h *handler)`, a line + // of a multi-line parameter list or `var (…)` block. Lowercase types + // are accepted only where a parameter list puts them: after `(`, `,` + // or nothing, and before `,`, `)` or the line's end — unlike the + // keyword-free pattern below, which a lowercase `ident type` pair in + // ordinary code would satisfy (#2323). + new RegExp(`(?:^|[(,])\\s*${r}\\s+\\*?([a-z_]\\w*)\\s*(?:\\[[^\\]]*\\])?\\s*(?:[,)]|$)`), // func (s *server) / f(h *handler) // A typed parameter / method receiver (`func use(lg Logger)`, // `func (l Logger) M()`) — name-before-type with no `var`/`:=` (#1125). // PascalCase-guarded (unlike the anchored patterns above) to keep the @@ -7538,6 +7581,13 @@ function inferLocalReceiverType( receiverName: string, ref: UnresolvedRef, context: ResolutionContext, + /** + * Receives the found type as the declaration spells it, before the + * normalization the return value gets: Go's package qualifier (`pkg.Type` + * vs a bare `server`) says which package declares it. Set on the + * incremental-scan path only, the one every Go receiver takes. + */ + decl?: { raw?: string }, ): string | null { // CFML scope prefixes: `variables.svc` / `this.svc` name a COMPONENT-scoped // field whose assignment or `property` declaration usually lives outside the @@ -7596,6 +7646,8 @@ function inferLocalReceiverType( ? 0 : Math.max(0, enclosingScopeStartLine(ref, context) - 1); + // The raw capture behind matchLine's latest non-null answer (see `decl`). + let lastRaw: string | undefined; const matchLine = (i: number): string | null => { const line = lines[i]; if (!line) return null; @@ -7607,7 +7659,10 @@ function inferLocalReceiverType( const m = line.match(re); if (m && m[1]) { const type = normalizeInferredTypeName(m[1]); - if (type) return type; + if (type) { + lastRaw = m[1]; + return type; + } } } return null; @@ -7634,7 +7689,8 @@ function inferLocalReceiverType( for (let i = callIdx; i >= startIdx; i--) { const type = matchLine(i); if (type) { - states.set(key, { hi: callIdx, ansIdx: i, ansType: type }); + states.set(key, { hi: callIdx, ansIdx: i, ansType: type, ansRaw: lastRaw }); + if (decl) decl.raw = lastRaw; return type; } } @@ -7647,15 +7703,21 @@ function inferLocalReceiverType( if (type) { state.ansIdx = i; state.ansType = type; + state.ansRaw = lastRaw; break; } } state.hi = callIdx; - return state.ansIdx >= startIdx ? state.ansType : null; + if (state.ansIdx < startIdx) return null; + if (decl) decl.raw = state.ansRaw; + return state.ansType; } for (let i = callIdx; i >= startIdx; i--) { const type = matchLine(i); - if (type) return type; + if (type) { + if (decl) decl.raw = lastRaw; + return type; + } } return null; } @@ -8028,10 +8090,11 @@ export function matchMethodCall( matchVbTypedCall(objectOrClass!, methodName!, ref, context, (name) => isStdMethodName('vbnet', name))); if (typed !== undefined) return typed; } + const decl: { raw?: string } = {}; let inferredType = nmTimedT('mc-infer', ref, () => ref.language === 'cpp' ? inferCppReceiverType(objectOrClass!, ref, context) - : inferLocalReceiverType(objectOrClass!, ref, context)); + : inferLocalReceiverType(objectOrClass!, ref, context, decl)); // A pytest test's parameter is what its fixture returns: flaskbb's // `cli_runner.invoke(…)` is click's `CliRunner`, not the project's one `invoke`. if (!inferredType && ref.language === 'python' && dotMatch) inferredType = pythonFixtureReturnType(objectOrClass!, ref, context); @@ -8055,13 +8118,16 @@ export function matchMethodCall( } if (inferredType) { // Java/Kotlin: when two classes share the simple name, the file's import - // pins WHICH one (#314). Other languages disambiguate by call-site file. + // pins WHICH one (#314); Go: the package that declares the type (#2323). + // Other languages disambiguate by call-site file. const importedFqn = ref.language === 'java' || ref.language === 'kotlin' ? context .getImportMappings(ref.filePath, ref.language) .find((i) => i.localName === inferredType)?.source - : undefined; + : ref.language === 'go' + ? goDeclaredTypePackage(decl.raw, ref.filePath, context) + : undefined; const typedMatch = nmTimedT('mc-rmot', ref, () => resolveMethodOnType( inferredType, methodName!, @@ -8547,6 +8613,128 @@ function isOutOfRepoBinding(name: string, ref: UnresolvedRef, context: Resolutio return !!binding && context.isOutOfRepoImport?.(binding.source, ref.filePath, ref.language) === true; } +/** The directory of a Go file, which is its package: Go keeps one package per directory. */ +function goPackageDir(filePath: string): string { + return path.posix.dirname(filePath.replace(/\\/g, '/')); +} + +/** + * The project directory of the package `filePath` imports as `qualifier`: + * undefined when the file has no such import, null when the import is not a + * package of one of the project's modules. + */ +function goImportPackageDir(qualifier: string, filePath: string, context: ResolutionContext): string | null | undefined { + const imp = context.getImportMappings(filePath, 'go').find((i) => i.localName === qualifier); + if (!imp) return undefined; + return context.getGoPackageDir?.(imp.source, filePath) ?? null; +} + +/** + * The package directory that declares a Go type spelled `raw` in `filePath`: + * the file's own package for a bare name (`server`, `*Store`), the imported + * package for a qualified one (`store.Manager`) when it is a project package. + * Undefined when that can't be told, which leaves resolution as it was. + */ +function goDeclaredTypePackage(raw: string | undefined, filePath: string, context: ResolutionContext): string | undefined { + if (!raw) return undefined; + const name = raw.replace(/[*&\s]/g, ''); + const dot = name.indexOf('.'); + if (dot < 0) return goPackageDir(filePath); + return goImportPackageDir(name.slice(0, dot), filePath, context) ?? undefined; +} + +/** The node kinds a Go `type` declaration produces. */ +const GO_TYPE_KINDS: ReadonlySet = new Set(['struct', 'interface', 'type_alias']); + +/** + * `methodName` on the Go type `typeName` that the package in directory + * `pkgDir` declares (#2323). Go type names are unique only within a package — + * each package may have its own `server`, `handler` or `DAO` — and a type's + * methods are always declared in its own package, so `matches` (every + * `::` in the project) counts only from `pkgDir`. A + * method the type doesn't declare is promoted from a type it embeds, which + * its declaration names (`BaseAPI`, `*cached.BaseManager`, `ReadView`); the + * name-based supertype walk would mix in every same-named type's embeddings. + * Undefined when the package declares no such type (a dot import, a file + * that isn't indexed): the caller then resolves by name as before. + */ +function resolveGoMethodInPackage( + typeName: string, + methodName: string, + pkgDir: string, + matches: Node[], + ref: UnresolvedRef, + context: ResolutionContext, + confidence: number, + resolvedBy: ResolvedRef['resolvedBy'], + depth: number, +): ResolvedRef | null | undefined { + const own = matches.filter((m) => goPackageDir(m.filePath) === pkgDir); + if (own.length > 0) { + if (ref.referenceKind === 'function_ref' && own.length !== 1) return null; + return { original: ref, targetNodeId: preferCallSiteFile(own, ref.filePath)[0]!.id, confidence, resolvedBy }; + } + const types = goPackageTypes(typeName, pkgDir, context); + if (types.length === 0) return undefined; + if (depth >= 4) return null; + for (const t of types) { + for (const embedded of goEmbeddedTypes(t, context)) { + const via = resolveMethodOnType( + embedded.name, methodName, ref, context, confidence, resolvedBy, embedded.pkgDir, depth + 1, + ); + if (via) return via; + } + } + return null; +} + +/** The declarations of Go type `typeName` in the package at directory `pkgDir`. */ +function goPackageTypes(typeName: string, pkgDir: string, context: ResolutionContext): Node[] { + return context.getNodesByName(typeName).filter( + (n) => n.language === 'go' && GO_TYPE_KINDS.has(n.kind) && goPackageDir(n.filePath) === pkgDir + ); +} + +const GO_EMBEDS = new WeakMap>>(); + +/** + * The project types a Go struct or interface embeds, read from its own + * declaration — a member that is nothing but a type (`BaseAPI`, + * `*cached.BaseManager`, `ReadView`, optionally tagged), one per line or + * `;`-separated (`type EphemeralKV struct{ RemoteKV }`) — each with the + * directory of the package that declares it. An embedded type from outside + * the module (`suite.Suite`, `sync.Mutex`) has no project methods and is left + * out, as is anything whose package can't be told. + */ +function goEmbeddedTypes(typeNode: Node, context: ResolutionContext): Array<{ name: string; pkgDir: string }> { + let memo = GO_EMBEDS.get(context); + if (!memo) GO_EMBEDS.set(context, (memo = new Map())); + const hit = memo.get(typeNode.id); + if (hit) return hit; + const embedded: Array<{ name: string; pkgDir: string }> = []; + const lines = context.getFileLines?.(typeNode.filePath) ?? context.readFile(typeNode.filePath)?.split(/\r?\n/) ?? []; + const decl = lines + .slice(Math.max(0, typeNode.startLine - 1), typeNode.endLine ?? typeNode.startLine) + .map((l) => l.replace(/\/\/.*$/, '').replace(/\/\*.*?\*\//g, '')) + .join('\n'); + const open = decl.indexOf('{'); + const close = decl.lastIndexOf('}'); + if (open >= 0 && close > open) { + let body = decl.slice(open + 1, close); + // Members of a nested anonymous struct are not this type's. + while (/\{[^{}]*\}/.test(body)) body = body.replace(/\{[^{}]*\}/g, ''); + for (const member of body.split(/[\n;]/)) { + const m = /^\s*\*?\s*([A-Za-z_]\w*)(?:\.([A-Za-z_]\w*))?\s*(?:\[[^\]]*\])?\s*(?:`[^`]*`|"[^"]*")?\s*$/.exec(member); + if (!m) continue; + const pkgDir = m[2] ? goImportPackageDir(m[1]!, typeNode.filePath, context) : goPackageDir(typeNode.filePath); + if (pkgDir == null) continue; + embedded.push({ name: m[2] ?? m[1]!, pkgDir }); + } + } + memo.set(typeNode.id, embedded); + return embedded; +} + /** Go builtin/primitive field types that can never carry a project method. */ const GO_BUILTIN_FIELD_TYPES = new Set([ 'string', 'bool', 'byte', 'rune', 'error', 'any', @@ -8579,15 +8767,25 @@ function matchGoFieldChainCall( if (segs.length !== 2 || !segs[0] || !segs[1]) return null; const [base, field] = segs; - const baseType = inferLocalReceiverType(base!, ref, context); + const decl: { raw?: string } = {}; + const baseType = inferLocalReceiverType(base!, ref, context, decl); if (!baseType) return null; const fieldEsc = field!.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const fieldTypeRe = new RegExp(`\\b${fieldEsc}\\s+\\*?\\[?\\]?([A-Za-z_][\\w.]*)`); - const structs = preferCallSiteFile(context.getNodesByName(baseType), ref.filePath).filter( + let structs = context.getNodesByName(baseType).filter( (n) => (n.kind === 'struct' || n.kind === 'class') && n.language === 'go' ); + // The struct the declaring package has is the one the base's type means (a + // method receiver's type is always the call site's own package's): Go type + // names repeat across packages — harbor has a `daoTestSuite` in every DAO + // package — and another package's same-named struct with a same-named + // field is not this one (#2323). + const basePkg = goDeclaredTypePackage(decl.raw, ref.filePath, context); + const declared = structs.filter((n) => goPackageDir(n.filePath) === basePkg); + if (declared.length > 0) structs = declared; + structs = preferCallSiteFile(structs, ref.filePath); for (const s of structs) { const source = context.readFile(s.filePath); if (!source) continue; @@ -8602,23 +8800,19 @@ function matchGoFieldChainCall( const m = line.match(fieldTypeRe); if (!m || !m[1]) continue; const rawType = m[1]; + // The package that declares the field's type: the struct's own for a + // bare type name, the imported one for a package-qualified name. + let pkgDir = goPackageDir(s.filePath); // A package-qualified field type (`http.Handler`, `sql.DB`) is only - // followed when the package is IN-MODULE: stripping the qualifier and - // matching the bare name would conflate a stdlib/third-party type with - // any same-named project type — on chi, `handler http.Handler` bound - // to an example app's unrelated local `Handler`. That is the exact - // fabrication this matcher exists to prevent (#1276). + // followed when the package is one of the project's: stripping the + // qualifier and matching the bare name would conflate a stdlib or + // third-party type with any same-named project type — on chi, `handler + // http.Handler` bound to an example app's unrelated local `Handler`. + // That is the exact 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 + '/')); - if (!inModule) continue; + const imported = goImportPackageDir(rawType.split('.')[0]!, s.filePath, context); + if (imported == null) continue; + pkgDir = imported; } // Unexported (lowercase) types are idiomatic Go and stay eligible — // chi's `mx.tree.FindRoute()` chains through `tree *node`. A @@ -8626,7 +8820,7 @@ function matchGoFieldChainCall( // validated `::` match. const fieldType = rawType.split('.').pop(); if (!fieldType || !/^[A-Za-z_]/.test(fieldType) || GO_BUILTIN_FIELD_TYPES.has(fieldType)) continue; - const resolved = resolveMethodOnType(fieldType, methodName, ref, context, 0.85, 'instance-method'); + const resolved = resolveMethodOnType(fieldType, methodName, ref, context, 0.85, 'instance-method', pkgDir); if (resolved) return resolved; } } diff --git a/src/resolution/types.ts b/src/resolution/types.ts index 1b1f11226..bd1abe9b7 100644 --- a/src/resolution/types.ts +++ b/src/resolution/types.ts @@ -187,13 +187,16 @@ 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-relative directory (`/`-separated, `.` for the root) of + * the Go package an import path names, when it is a package of one of the + * project's own modules — the `go.mod` at the root or any `go.mod` above + * an indexed `.go` file (#2322) — else `null` (the standard library, + * third-party modules, a project with no `go.mod`). `fromFile`, the + * importing file, breaks a tie between two modules declaring one path. + * Used by the Go branch of resolution to tell in-project cross-package + * imports from outside ones, and to find the package's files. */ - getGoModule?(): import('./go-module').GoModule | null; + getGoPackageDir?(importPath: string, fromFile?: string): string | null; /** * Monorepo workspace member packages, keyed by declared package name. * Returns `null` for single-package repos (no `workspaces` field).