`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:
@@ -36,6 +36,18 @@ export const goExtractor: LanguageExtractor = {
|
||||
if (typeChild.type === 'interface_type') return 'interface';
|
||||
return undefined;
|
||||
},
|
||||
isExported: (node, source) => {
|
||||
// Go: a symbol is exported when its identifier starts with an uppercase letter.
|
||||
// Look at the `name` field directly (works for function_declaration,
|
||||
// method_declaration, type_spec, and var_spec / const_spec via extractor flow).
|
||||
const nameNode = getChildByField(node, 'name');
|
||||
if (nameNode) {
|
||||
const text = getNodeText(nameNode, source);
|
||||
const first = text.charCodeAt(0);
|
||||
return first >= 65 && first <= 90; // A-Z
|
||||
}
|
||||
return false;
|
||||
},
|
||||
getReceiverType: (node, source) => {
|
||||
// Go method_declaration has a "receiver" field: func (sl *scrapeLoop) run(...)
|
||||
// The receiver is a parameter_list containing a parameter_declaration
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Go module path detection.
|
||||
*
|
||||
* A Go monorepo's cross-package calls (`pkga.FuncX(...)`) only resolve when
|
||||
* the resolver knows the project's module path (the `module ...` directive
|
||||
* in `go.mod`). Without it, `isExternalImport` treats every in-module import
|
||||
* — `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.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
export interface GoModule {
|
||||
/** The module path declared in `go.mod`, e.g. `github.com/example/myproject` */
|
||||
modulePath: string;
|
||||
/** Absolute path to the directory containing the `go.mod` file. */
|
||||
rootDir: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function loadGoModule(projectRoot: string): GoModule | null {
|
||||
const goModPath = path.join(projectRoot, 'go.mod');
|
||||
let content: string;
|
||||
try {
|
||||
content = fs.readFileSync(goModPath, 'utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
// `module <path>` is the first non-comment directive in any valid go.mod.
|
||||
// Strip line comments so a `// module foo` doesn't false-match.
|
||||
const stripped = content.replace(/\/\/[^\n]*/g, '');
|
||||
const match = stripped.match(/^\s*module\s+(\S+)\s*$/m);
|
||||
if (!match) return null;
|
||||
// Strip optional quoting around the module path.
|
||||
const modulePath = match[1]!.replace(/^["']|["']$/g, '');
|
||||
if (!modulePath) return null;
|
||||
return { modulePath, rootDir: projectRoot };
|
||||
}
|
||||
@@ -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 2–3 deep; 8 is a generous safety
|
||||
* net that still bounds worst-case work. */
|
||||
|
||||
@@ -21,6 +21,7 @@ import { resolveViaImport, extractImportMappings, extractReExports } from './imp
|
||||
import { detectFrameworks } from './frameworks';
|
||||
import { synthesizeCallbackEdges } from './callback-synthesizer';
|
||||
import { loadProjectAliases, type AliasMap } from './path-aliases';
|
||||
import { loadGoModule, type GoModule } from './go-module';
|
||||
import { logDebug } from '../errors';
|
||||
import type { ReExport } from './types';
|
||||
import { LRUCache } from './lru-cache';
|
||||
@@ -157,6 +158,8 @@ export class ReferenceResolver {
|
||||
// `null` = computed and absent. Treated as immutable for the
|
||||
// resolver's lifetime; callers re-create the resolver if config changes.
|
||||
private projectAliases: AliasMap | null | undefined = undefined;
|
||||
// go.mod module path. Same lazy/immutable convention as projectAliases.
|
||||
private goModule: GoModule | null | undefined = undefined;
|
||||
|
||||
constructor(projectRoot: string, queries: QueryBuilder) {
|
||||
this.projectRoot = projectRoot;
|
||||
@@ -370,6 +373,13 @@ export class ReferenceResolver {
|
||||
return this.projectAliases;
|
||||
},
|
||||
|
||||
getGoModule: () => {
|
||||
if (this.goModule === undefined) {
|
||||
this.goModule = loadGoModule(this.projectRoot);
|
||||
}
|
||||
return this.goModule;
|
||||
},
|
||||
|
||||
getReExports: (filePath: string, language) => {
|
||||
const cached = this.reExportCache.get(filePath);
|
||||
if (cached) return cached;
|
||||
|
||||
@@ -91,6 +91,14 @@ export interface ResolutionContext {
|
||||
* compile without modification; production resolver implements it.
|
||||
*/
|
||||
getProjectAliases?(): 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.
|
||||
*/
|
||||
getGoModule?(): import('./go-module').GoModule | null;
|
||||
/**
|
||||
* Re-exports declared by a file (`export { x } from './other'`,
|
||||
* `export * from './other'`). Empty array when the file has none.
|
||||
|
||||
Reference in New Issue
Block a user