fix(resolution): Go cross-package qualified calls resolve via go.mod (#388) (#469)

`pkga.FuncX(...)` cross-package calls in Go monorepos were dropping
through the import resolver — `isExternalImport(go)` flagged any
non-`/internal/` import as third-party because the resolver had no idea
what the project's own module path was. Resolution fell back to name
matching with path-proximity scoring, which on a layered codebase picks
one accidental candidate per call site (~<1% recall per #388's
5,303-vs-1 figure).

- `src/resolution/go-module.ts` (new) parses the `module ...` directive
  from project-root `go.mod`, exposed via `getGoModule()` on
  `ResolutionContext`.
- `isExternalImport(go)` treats `<module-path>/...` imports as in-module;
  the existing `/internal/` escape hatch is preserved for repos without
  a parsed go.mod.
- `resolveViaImport` gets a Go cross-package branch that strips the
  module prefix to a project-relative directory, then resolves the
  qualified member via `getNodesByName(member)` filtered to that exact
  directory and `isExported=true`. Sub-packages don't collide with their
  parents; same-name funcs in different packages don't false-merge.
- Go extractor sets `isExported` from the identifier's first character
  (Go's universal uppercase=exported convention). The resolver depends
  on this to filter candidates.

Validation on gRPC-Go (1,031 .go files, layered package tree):
  total `calls` edges:    23,803 -> 34,105 (+43%)
  cross-pkg `calls`:      10,880 -> 19,929 (+83%)
  fmt/strconv/etc. stdlib calls: stay external (no false positives)

Tests cover in-module disambiguation with same-name funcs in two
packages, aliased imports, and stdlib calls not being false-resolved to
in-project nodes.

Closes #388.
This commit is contained in:
Colby Mchenry
2026-05-26 17:14:35 -05:00
committed by GitHub
parent 7e0d9b9ec0
commit f1b79eeae1
7 changed files with 283 additions and 3 deletions
+84 -3
View File
@@ -103,10 +103,24 @@ function isExternalImport(
}
if (language === 'go') {
// Standard library or external packages
if (!importPath.startsWith('.') && !importPath.includes('/internal/')) {
return true;
// Relative imports (rare in idiomatic Go but the grammar allows them).
if (importPath.startsWith('.')) {
return false;
}
// In-module imports look like `<module-path>/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 + '/'))) {
return false;
}
// `internal/` packages stay local even when go.mod is missing —
// preserves the pre-#388 escape hatch for repos without a parsed module path.
if (importPath.includes('/internal/')) {
return false;
}
// Anything else is the Go standard library or a third-party module.
return true;
}
return false;
@@ -596,6 +610,15 @@ export function resolveViaImport(
return null;
}
// Go cross-package calls: `pkga.FuncX(...)` extracts to referenceName
// `pkga.FuncX` and the import `github.com/example/myproject/pkga`
// maps to a *package directory* containing one or more .go files.
// The generic file-based lookup below can't follow that — issue #388.
if (ref.language === 'go') {
const goResult = resolveGoCrossPackageReference(ref, imports, context);
if (goResult) return goResult;
}
// Check if the reference name matches any import
for (const imp of imports) {
if (imp.localName === ref.referenceName || ref.referenceName.startsWith(imp.localName + '.')) {
@@ -636,6 +659,64 @@ export function resolveViaImport(
return null;
}
/**
* 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.
*/
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.
const dotIdx = ref.referenceName.indexOf('.');
if (dotIdx <= 0) return 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);
// 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',
};
}
}
}
return null;
}
/** Recursive depth cap for re-export chain following. Real codebases
* rarely chain barrels more than 23 deep; 8 is a generous safety
* net that still bounds worst-case work. */