From ad830e84144870e06b720982383ffa38ce3ece5d Mon Sep 17 00:00:00 2001 From: ericquan8 Date: Thu, 6 Aug 2026 18:59:05 +0800 Subject: [PATCH] fix(go): resolve cross-module calls in multi-module layouts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Go monorepo with several independent modules under one root (and no root go.mod) resolved cross-module calls at ~4% recall / ~7% precision: the single-module reader only checked projectRoot/go.mod, so every in-repo import was classified third-party and resolution fell through to global name-matching, wiring most cross-module calls to the wrong target. A wrong edge is worse than a missing one for impact analysis. This is the follow-up #388's own code comment asked for. Multi-module import resolution: - loadGoModules(): index EVERY go.mod under projectRoot (skip-list, depth/count caps, entries sorted by modulePath length DESCENDING so the longest prefix wins, deterministic traversal, shortest-relDir wins on a duplicated module path). packageDir() reproduces the old single-module algorithm byte-for-byte when relDir=''; it can legally return '', so callers test === null. loadGoModule()/GoModule untouched. - ResolutionContext.getGoModules?() added (optional); wired as a lazy, memoised provider alongside getGoModule(), matching the existing path-aliases / workspace-packages convention. Like those, it is resolver metadata only — it produces no nodes and no edges. - isExternalImport (Go branch), resolveGoCrossPackageReference and the name-matcher Go field-type guard prefer the index and fall back to the single-module path verbatim. The exact-parent match (fileDir === pkgDir) and the #1276 anti-fabrication guard are preserved. Two extraction-layer defects with the same symptom (both reproduce in a single-module repo too): - Go const/var/method isExported was never set, so the resolver's `if (!node.isExported) continue` dropped them. extractMethod never called the hook; the Go const/var branch omitted it AND would have fed the hook the declaration node, which has no `name` field — the identifier is on the const_spec/var_spec child. Computed per-spec inside the Go branch; the go.ts hook and the shared declaration-level isExported are untouched. - Grouped `var (...)` produced zero nodes. tree-sitter-go wraps a grouped var's specs in a `var_spec_list` node while a grouped const's specs are direct children, so the direct-child filter found nothing. Flattening the wrapper also restores calls made inside package-level grouped-var initializers, which previously had no source node at all. Verification (reproducible) — 14 tests build real multi-module layouts in temp dirs and index them for real, no mocks: npx vitest run __tests__/resolution.test.ts -t "Go multi-module" npx vitest run __tests__/extraction.test.ts -t "Go const/var extraction" covering cross-module resolution, same-name symbol in a non-imported module must not be picked, longest-prefix precedence, single-root-module no-regress, no-go.mod no-op, sub-package exclusion, scan-depth cap, grouped var/const extraction, isExported across all four const/var forms, and TS/Python/Rust no-regress. Full suite green. Scale check (private repo, NOT reproducible here; reported for magnitude). 11.5k Go files, 61 side-by-side modules. Ground truth is derived from the source with no manual labelling — an import alias binds to one module path, which maps to one local directory; a target defined exactly once in that directory is unambiguous. Anything ambiguous is discarded, so the numbers are conservative. cross-module call recall 4.03% -> 99.67% cross-module target precision 6.83% -> 100% file coverage 99.94% -> 99.94% call-site line precision 99.20% -> 99.73% node count 302,900 -> 311,347 (+2.8%, grouped vars) Known gap, out of scope: cross-module references to package-level const/var still do not resolve. Value-position `alias.Symbol` references are never EXTRACTED as references (flushValueRefs is same-file only by design), so they never reach the resolver regardless of isExported. The isExported fix above is still correct, but it removes a guard nothing currently reaches. The real fix is an extraction-layer change, tracked separately. Design notes: docs/design/go-multi-module-resolution.md Refs #388. --- CHANGELOG.md | 6 + __tests__/extraction.test.ts | 67 +++++ __tests__/resolution.test.ts | 285 ++++++++++++++++++++++ docs/design/go-multi-module-resolution.md | 219 +++++++++++++++++ src/extraction/tree-sitter.ts | 30 ++- src/resolution/go-module.ts | 146 ++++++++++- src/resolution/import-resolver.ts | 35 ++- src/resolution/index.ts | 13 +- src/resolution/name-matcher.ts | 12 +- src/resolution/types.ts | 7 + 10 files changed, 800 insertions(+), 20 deletions(-) create mode 100644 docs/design/go-multi-module-resolution.md diff --git a/CHANGELOG.md b/CHANGELOG.md index d2ae567d0..e02a59614 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Fixes + +- In a Go project with several modules placed side by side under one directory (a common monorepo layout), a call from one module into another now resolves to the correct definition instead of being dropped or wired to the wrong symbol (#388). +- Exported Go methods are now correctly marked as exported, so cross-package method calls resolve. +- In a Go project, variables declared in a grouped `var (...)` block are now indexed, and exported package-level constants and variables are now correctly marked as exported. + ## [1.5.0] - 2026-07-21 diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index 784023952..b1d2a16b1 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -347,6 +347,73 @@ in }); }); +describe('Go const/var extraction (grouped var + isExported)', () => { + it('extracts every entry in a grouped var (…) block', () => { + // Defect B: a grouped `var ( … )` wraps its specs in a `var_spec_list` + // node (tree-sitter-go asymmetry vs grouped const), so they were skipped. + const code = `package main + +var ( + A = 1 + b = 2 +)`; + const result = extractFromSource('main.go', code); + const names = result.nodes.filter((n) => n.kind === 'variable').map((n) => n.name); + expect(names).toContain('A'); + expect(names).toContain('b'); + }); + + it('does not regress grouped const (…) extraction', () => { + const code = `package main + +const ( + C = 1 + D = 2 +)`; + const result = extractFromSource('main.go', code); + const names = result.nodes.filter((n) => n.kind === 'constant').map((n) => n.name).sort(); + expect(names).toEqual(['C', 'D']); + }); + + it('sets isExported by leading-case for all Go var/const forms', () => { + // Defect A: Go const/variable isExported was always 0. All four forms here. + const code = `package main + +var SingleVar = 1 +var ( + GroupedA = 1 + groupedB = 2 +) +const SingleConst = 1 +const ( + ConstA = 1 + constB = 2 +)`; + const result = extractFromSource('main.go', code); + const node = (kind: string, name: string) => + result.nodes.find((n) => n.kind === kind && n.name === name); + expect(node('variable', 'SingleVar')?.isExported).toBe(true); + expect(node('variable', 'GroupedA')?.isExported).toBe(true); + expect(node('variable', 'groupedB')?.isExported).toBe(false); + expect(node('constant', 'SingleConst')?.isExported).toBe(true); + expect(node('constant', 'ConstA')?.isExported).toBe(true); + expect(node('constant', 'constB')?.isExported).toBe(false); + }); + + it('does not change isExported behavior for TypeScript or Python (Go-only fix)', () => { + // TypeScript: export-marked const is exported, plain const is not. + const ts = extractFromSource('a.ts', 'export const Exp = 1;\nconst priv = 2;\n'); + expect(ts.nodes.find((n) => n.kind === 'constant' && n.name === 'Exp')?.isExported).toBe(true); + expect(ts.nodes.find((n) => n.kind === 'constant' && n.name === 'priv')?.isExported).toBeFalsy(); + + // Python has no isExported predicate — symbols stay not-exported (unchanged). + const py = extractFromSource('a.py', 'MAX = 100\ncounter = 0\n'); + const pyMax = py.nodes.find((n) => (n.kind === 'constant' || n.kind === 'variable') && n.name === 'MAX'); + expect(pyMax).toBeDefined(); + expect(pyMax?.isExported).toBeFalsy(); + }); +}); + describe('TypeScript Extraction', () => { it('should extract function declarations', () => { const code = ` diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index eca1778ff..dd6a3e4ef 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -13,6 +13,7 @@ import { Node, UnresolvedReference } from '../src/types'; import { ReferenceResolver, createResolver, ResolutionContext } from '../src/resolution'; import { matchReference, resolveMethodOnType, matchByQualifiedName, preferCallSiteFile, matchMethodCall } from '../src/resolution/name-matcher'; import { resolveImportPath, extractImportMappings, resolveJvmImport, loadCppIncludeDirs, clearCppIncludeDirCache, isPhpIncludePathRef } from '../src/resolution/import-resolver'; +import { loadGoModules } from '../src/resolution/go-module'; import type { UnresolvedRef } from '../src/resolution/types'; import { detectFrameworks, getAllFrameworkResolvers } from '../src/resolution/frameworks'; import { QueryBuilder } from '../src/db/queries'; @@ -1153,6 +1154,290 @@ func UsePkga() { expect(target?.filePath.replace(/\\/g, '/')).toBe('pkga/conv.go'); }); + describe('Go multi-module (side-by-side modules) resolution', () => { + // A monorepo layout where several independent Go modules sit as siblings + // under one root (no root go.mod). Pre-fix, loadGoModule read only the + // (absent) root go.mod → null → every cross-module import was treated as + // third-party and calls fell through to global name-matching (4% recall). + // These tests pin the multi-module index behavior. No mocks: every case + // writes real .go files and resolves through a real SQLite index. + + it('1. resolves a cross-module call between two side-by-side modules', async () => { + fs.mkdirSync(path.join(tempDir, 'a', 'pkg'), { recursive: true }); + fs.mkdirSync(path.join(tempDir, 'b', 'pkg'), { recursive: true }); + fs.writeFileSync(path.join(tempDir, 'a', 'go.mod'), 'module example.com/a\n\ngo 1.21\n'); + fs.writeFileSync(path.join(tempDir, 'a', 'pkg', 'shared.go'), 'package pkg\n\nfunc SharedFn() int { return 1 }\n'); + fs.writeFileSync(path.join(tempDir, 'b', 'go.mod'), 'module example.com/b\n\ngo 1.21\n'); + // Decoy with the SAME name in b's own sub-package: under the broken + // (global name-match) resolver this is the closer hit, so resolving to + // a/pkg is what proves import-based cross-module resolution fired. + fs.writeFileSync(path.join(tempDir, 'b', 'pkg', 'other.go'), 'package pkg\n\nfunc SharedFn() int { return 2 }\n'); + fs.writeFileSync(path.join(tempDir, 'b', 'use.go'), + `package main + +import apkg "example.com/a/pkg" + +func UseShared() { + apkg.SharedFn() +} +`); + + cg = await CodeGraph.init(tempDir, { index: true }); + const useShared = cg.getNodesByKind('function').filter((n) => n.name === 'UseShared')[0]; + expect(useShared).toBeDefined(); + const calls = cg.getOutgoingEdges(useShared!.id).filter((e) => e.kind === 'calls'); + const targets = calls.map((e) => cg.getNode(e.target)?.filePath.replace(/\\/g, '/')); + expect(targets).toContain('a/pkg/shared.go'); + expect(targets).not.toContain('b/pkg/other.go'); + }); + + it('2. does not cross-wire a same-named symbol to a non-imported module', async () => { + // Mirrors a real mis-wire: a call to a same-named helper (Init) that the + // index wired to an unrelated module. b imports a ONLY; its apkg.Init() + // must resolve to a's Init, never c's decoy Init. + fs.mkdirSync(path.join(tempDir, 'a', 'pkg'), { recursive: true }); + fs.mkdirSync(path.join(tempDir, 'b'), { recursive: true }); + fs.mkdirSync(path.join(tempDir, 'c', 'pkg'), { recursive: true }); + fs.writeFileSync(path.join(tempDir, 'a', 'go.mod'), 'module example.com/a\n\ngo 1.21\n'); + fs.writeFileSync(path.join(tempDir, 'a', 'pkg', 'init.go'), 'package pkg\n\nfunc Init() int { return 1 }\n'); + fs.writeFileSync(path.join(tempDir, 'c', 'go.mod'), 'module example.com/c\n\ngo 1.21\n'); + fs.writeFileSync(path.join(tempDir, 'c', 'pkg', 'init.go'), 'package pkg\n\nfunc Init() int { return 2 }\n'); + fs.writeFileSync(path.join(tempDir, 'b', 'go.mod'), 'module example.com/b\n\ngo 1.21\n'); + fs.writeFileSync(path.join(tempDir, 'b', 'use.go'), + `package main + +import apkg "example.com/a/pkg" + +func UseInit() { + apkg.Init() +} +`); + + cg = await CodeGraph.init(tempDir, { index: true }); + const useInit = cg.getNodesByKind('function').filter((n) => n.name === 'UseInit')[0]; + expect(useInit).toBeDefined(); + const calls = cg.getOutgoingEdges(useInit!.id).filter((e) => e.kind === 'calls'); + const targets = calls.map((e) => cg.getNode(e.target)?.filePath.replace(/\\/g, '/')); + expect(targets).toContain('a/pkg/init.go'); + expect(targets).not.toContain('c/pkg/init.go'); + }); + + it('3. resolves the longest matching module path prefix first', async () => { + // example.com/x/commons and example.com/x/commons/sdk coexist. An import + // of .../sdk/pkg must bind to the SDK module, never the shorter commons + // prefix (which would map it to lib/sdk/pkg where no symbol lives). + fs.mkdirSync(path.join(tempDir, 'lib'), { recursive: true }); + fs.mkdirSync(path.join(tempDir, 'sdk', 'pkg'), { recursive: true }); + fs.mkdirSync(path.join(tempDir, 'app'), { recursive: true }); + fs.writeFileSync(path.join(tempDir, 'lib', 'go.mod'), 'module example.com/x/commons\n\ngo 1.21\n'); + fs.writeFileSync(path.join(tempDir, 'sdk', 'go.mod'), 'module example.com/x/commons/sdk\n\ngo 1.21\n'); + fs.writeFileSync(path.join(tempDir, 'sdk', 'pkg', 'thing.go'), 'package pkg\n\nfunc SdkThing() int { return 1 }\n'); + fs.writeFileSync(path.join(tempDir, 'app', 'go.mod'), 'module example.com/x/app\n\ngo 1.21\n'); + fs.writeFileSync(path.join(tempDir, 'app', 'use.go'), + `package main + +import sdkpkg "example.com/x/commons/sdk/pkg" + +func UseSdk() { + sdkpkg.SdkThing() +} +`); + + // Direct index check: SDK wins the prefix race → sdk/pkg, not lib/sdk/pkg. + const idx = loadGoModules(tempDir); + expect(idx).not.toBeNull(); + expect(idx!.packageDir('example.com/x/commons/sdk/pkg')).toBe('sdk/pkg'); + // The shorter module is still itself resolvable at its own path. + expect(idx!.packageDir('example.com/x/commons')).toBe('lib'); + + cg = await CodeGraph.init(tempDir, { index: true }); + const useSdk = cg.getNodesByKind('function').filter((n) => n.name === 'UseSdk')[0]; + expect(useSdk).toBeDefined(); + const calls = cg.getOutgoingEdges(useSdk!.id).filter((e) => e.kind === 'calls'); + const targets = calls.map((e) => cg.getNode(e.target)?.filePath.replace(/\\/g, '/')); + expect(targets).toContain('sdk/pkg/thing.go'); + }); + + it('4. matches the old algorithm for a single root module with no nested modules', async () => { + // A single go.mod at the project root: the new index path must reproduce + // the exact packageDir the old single-module code produced (table rows + // for relDir=''). This is the backward-compat guarantee. + fs.writeFileSync(path.join(tempDir, 'go.mod'), 'module example.com/app\n\ngo 1.21\n'); + fs.mkdirSync(path.join(tempDir, 'pkga')); + fs.writeFileSync(path.join(tempDir, 'pkga', 'helper.go'), 'package pkga\n\nfunc Helper() int { return 1 }\n'); + fs.writeFileSync(path.join(tempDir, 'main.go'), + `package main + +import "example.com/app/pkga" + +func UseHelper() { + pkga.Helper() +} +`); + + const idx = loadGoModules(tempDir); + expect(idx).not.toBeNull(); + expect(idx!.packageDir('example.com/app')).toBe(''); + expect(idx!.packageDir('example.com/app/pkga')).toBe('pkga'); + expect(idx!.packageDir('example.com/other')).toBeNull(); + + cg = await CodeGraph.init(tempDir, { index: true }); + const useHelper = cg.getNodesByKind('function').filter((n) => n.name === 'UseHelper')[0]; + expect(useHelper).toBeDefined(); + const calls = cg.getOutgoingEdges(useHelper!.id).filter((e) => e.kind === 'calls'); + const targets = calls.map((e) => cg.getNode(e.target)?.filePath.replace(/\\/g, '/')); + expect(targets).toContain('pkga/helper.go'); + }); + + it('5. returns null and stays a no-op when there is no go.mod', async () => { + // No go.mod anywhere → loadGoModules is null and downstream behaves as today. + expect(loadGoModules(tempDir)).toBeNull(); + // A non-Go project must still index normally (absence of go.mod never throws). + fs.writeFileSync(path.join(tempDir, 'app.ts'), 'export function hi(): number { return 1; }\n'); + cg = await CodeGraph.init(tempDir, { index: true }); + const hi = cg.getNodesByKind('function').filter((n) => n.name === 'hi')[0]; + expect(hi).toBeDefined(); + }); + + it('6. does not match a symbol in a sub-package of the imported package', async () => { + // pkga.FuncX must land on pkga/top.go, never pkga/subpkg/sub.go. This is + // the `fileDir === pkgDir` (exact parent) guard, not startsWith. + fs.writeFileSync(path.join(tempDir, 'go.mod'), 'module example.com/app\n\ngo 1.21\n'); + fs.mkdirSync(path.join(tempDir, 'pkga', 'subpkg'), { recursive: true }); + fs.writeFileSync(path.join(tempDir, 'pkga', 'top.go'), 'package pkga\n\nfunc FuncX() int { return 1 }\n'); + fs.writeFileSync(path.join(tempDir, 'pkga', 'subpkg', 'sub.go'), 'package subpkg\n\nfunc FuncX() int { return 2 }\n'); + fs.writeFileSync(path.join(tempDir, 'main.go'), + `package main + +import "example.com/app/pkga" + +func UseFuncX() { + pkga.FuncX() +} +`); + + cg = await CodeGraph.init(tempDir, { index: true }); + const useFuncX = cg.getNodesByKind('function').filter((n) => n.name === 'UseFuncX')[0]; + expect(useFuncX).toBeDefined(); + const calls = cg.getOutgoingEdges(useFuncX!.id).filter((e) => e.kind === 'calls'); + const targets = calls.map((e) => cg.getNode(e.target)?.filePath.replace(/\\/g, '/')); + expect(targets).toContain('pkga/top.go'); + expect(targets).not.toContain('pkga/subpkg/sub.go'); + }); + + it('7. marks exported methods isExported (Go) with no cross-language regression (Rust)', async () => { + // Go: a receiver method's isExported must reflect the leading-case rule, + // matching extractFunction. Before this fix extractMethod never set it. + fs.writeFileSync(path.join(tempDir, 'go.mod'), 'module example.com/app\n\ngo 1.21\n'); + fs.writeFileSync(path.join(tempDir, 'thing.go'), + `package main + +type T struct {} + +func (r *T) Exported() int { return 1 } +func (r *T) unexported() int { return 2 } +`); + // Rust: no isExported predicate exists, so methods stay not-exported — + // unchanged by the Go-focused fix. Both methods must still extract. + fs.writeFileSync(path.join(tempDir, 'lib.rs'), + `pub struct Counter { count: i32 } + +impl Counter { + pub fn increment(&mut self) { self.count += 1; } + fn decrement(&mut self) { self.count -= 1; } +} +`); + + cg = await CodeGraph.init(tempDir, { index: true }); + + const goMethods = cg.getNodesByKind('method').filter((m) => m.language === 'go' && m.filePath.replace(/\\/g, '/') === 'thing.go'); + const exp = goMethods.find((m) => m.name === 'Exported'); + const unexp = goMethods.find((m) => m.name === 'unexported'); + expect(exp).toBeDefined(); + expect(unexp).toBeDefined(); + expect(exp!.isExported).toBe(true); + expect(unexp!.isExported).toBe(false); + + const rustMethods = cg.getNodesByKind('method').filter((m) => m.language === 'rust' && m.filePath.replace(/\\/g, '/') === 'lib.rs'); + expect(rustMethods.find((m) => m.name === 'increment')).toBeDefined(); + expect(rustMethods.find((m) => m.name === 'decrement')).toBeDefined(); + }); + + it('8. loadGoModules respects the scan-depth cap without throwing', () => { + // Nest go.mod files deeper than the MAX_DEPTH (8) safety cap. The scan + // must not throw, must collect the in-range modules, and must stop before + // the out-of-range ones. + let dir = tempDir; + for (let i = 0; i < 12; i++) { + dir = path.join(dir, `d${i}`); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'go.mod'), `module example.com/deep${i}\n\ngo 1.21\n`); + } + expect(() => loadGoModules(tempDir)).not.toThrow(); + const idx = loadGoModules(tempDir); + expect(idx).not.toBeNull(); + const modulePaths = idx!.entries.map((e) => e.modulePath); + expect(modulePaths).toContain('example.com/deep0'); + expect(modulePaths).not.toContain('example.com/deep11'); + }); + + it('resolves a same-file package-constant reference (cross-module value refs need separate extraction)', async () => { + // Defect A makes exported Go const/var isExported=true so they pass the + // resolver's `if (!node.isExported) continue` guard. The const-reference + // edge that exists today is the SAME-FILE value-ref path (flushValueRefs); + // a cross-module `alias.Const` value reference is not yet EXTRACTED as a + // reference at all, so it cannot resolve here — that is a separate + // extraction gap, tracked outside this change. This test pins the path + // that works and the defect-A eligibility fix. + fs.writeFileSync(path.join(tempDir, 'go.mod'), 'module example.com/app\n\ngo 1.21\n'); + fs.writeFileSync(path.join(tempDir, 'main.go'), + `package main + +const SharedCode = "x" + +func UseCode() string { + return SharedCode +} +`); + + cg = await CodeGraph.init(tempDir, { index: true }); + const code = cg.getNodesByKind('constant').filter((n) => n.name === 'SharedCode')[0]; + const useCode = cg.getNodesByKind('function').filter((n) => n.name === 'UseCode')[0]; + expect(code?.isExported).toBe(true); // defect A: exported const no longer guard-blocked + const refsToCode = cg.getOutgoingEdges(useCode!.id).filter( + (e) => e.kind === 'references' && cg.getNode(e.target)?.name === 'SharedCode' + ); + expect(refsToCode.length).toBeGreaterThan(0); + }); + + it('builds a calls edge for a cross-module call inside a package-level grouped var initializer', async () => { + // Defect B consequence: a grouped-var initializer had no source node, + // so the call inside it (`apkg.NewLogger`) got no edge. Now `logger` + // is a node and its initializer call resolves to module a. + fs.mkdirSync(path.join(tempDir, 'a', 'pkg'), { recursive: true }); + fs.mkdirSync(path.join(tempDir, 'b'), { recursive: true }); + fs.writeFileSync(path.join(tempDir, 'a', 'go.mod'), 'module example.com/a\n\ngo 1.21\n'); + fs.writeFileSync(path.join(tempDir, 'a', 'pkg', 'logger.go'), 'package pkg\n\ntype Logger struct{}\n\nfunc NewLogger(name string) *Logger { return nil }\n'); + fs.writeFileSync(path.join(tempDir, 'b', 'go.mod'), 'module example.com/b\n\ngo 1.21\n'); + fs.writeFileSync(path.join(tempDir, 'b', 'use.go'), + `package main + +import apkg "example.com/a/pkg" + +var ( + logger = apkg.NewLogger("x") +) +`); + + cg = await CodeGraph.init(tempDir, { index: true }); + const loggerVar = cg.getNodesByKind('variable').filter((n) => n.name === 'logger' && n.filePath.replace(/\\/g, '/') === 'b/use.go')[0]; + expect(loggerVar).toBeDefined(); + const calls = cg.getOutgoingEdges(loggerVar!.id).filter((e) => e.kind === 'calls'); + const targets = calls.map((e) => cg.getNode(e.target)?.filePath.replace(/\\/g, '/')); + expect(targets).toContain('a/pkg/logger.go'); + }); + }); + it('resolves Go aliased imports across packages (#388)', async () => { fs.writeFileSync( path.join(tempDir, 'go.mod'), diff --git a/docs/design/go-multi-module-resolution.md b/docs/design/go-multi-module-resolution.md new file mode 100644 index 000000000..8229d57fb --- /dev/null +++ b/docs/design/go-multi-module-resolution.md @@ -0,0 +1,219 @@ +# Go multi-module (monorepo-of-modules) import resolution + +**Status**: implemented +**Refs**: #388 — this is the follow-up its own code comment asked for +("Limitation: only the project-root `go.mod` is read. Nested `go.mod` files +… are not yet resolved — a follow-up if a real repro shows up.") + +--- + +## 1. The problem + +A layout where several **independent Go modules sit side by side under one +root**, and the root itself has **no `go.mod`**: + +``` +repo/ ← the directory codegraph indexes +├── (no go.mod here) +├── service-a/go.mod module example.com/org/service-a +├── commons/go.mod module example.com/org/commons +├── commons/basic/go.mod module example.com/org/commons/basic +└── sdk/go.mod module example.com/org/platform/sdk +``` + +Note `sdk`: its module path (`example.com/org/platform/sdk`) is **not** a +prefix-extension of its directory name, and several module paths share +ancestor segments. Prefix matching therefore has to be longest-first. + +In this layout essentially every cross-module call failed to resolve and +degraded to global name matching, producing **wrong** edges — not merely +missing ones. Typical mis-wiring: a call to a `Init` helper in one module +resolved to an unrelated same-named `Init` in a different module. + +For an impact-analysis tool, a wrong edge is worse than a missing one. + +## 2. Root cause + +### 2.1 `loadGoModule` only reads `/go.mod` + +`src/resolution/go-module.ts` read a single hard-coded path, so a root +without its own `go.mod` yielded `null`. That `null` propagated to two +decision points: + +**`isExternalImport`** (`src/resolution/import-resolver.ts`, Go branch) — +without a module path to compare against, every in-repo import +(`example.com/org/commons/...`) was classified as a **third-party package**, +so import-based resolution was skipped entirely. + +**`resolveGoCrossPackageReference`** (same file) — bailed on the first line +(`if (!mod) return null`) and let the reference fall through to +name-matching with path proximity. + +The existing comment at the top of `go-module.ts` had already predicted the +exact symptom: *"resolution falls through to name-matching with path +proximity and returns a tiny fraction of the real call sites."* + +### 2.2 Two independent extraction-layer defects with the same symptom + +Both block Go package-level symbols from being usable cross-module, and both +reproduce in a **single-module** repo too. + +**(a) `isExported` never set on Go `const`/`var` (and on `method`).** +`resolveGoCrossPackageReference` guards on `if (!node.isExported) continue`. +`extractMethod` never called the `isExported` hook at all (unlike +`extractFunction` / `extractClass` / `extractInterface`), and the Go +`const`/`var` branch of `extractVariable` both omitted the property *and* +would have fed the hook the wrong node — the hook reads +`getChildByField(node, 'name')`, but a `const_declaration` / `var_declaration` +has no `name` field; the identifier lives on the `const_spec` / `var_spec` +child. + +**(b) Grouped `var ( … )` produced zero nodes.** tree-sitter-go is +asymmetric here: + +``` +var ( A = 1 B = 2 ) → var_declaration [ var_spec_list ] ← wrapped +const ( C = 1 D = 2 ) → const_declaration [ const_spec, const_spec ] ← direct +var A = 1 → var_declaration [ var_spec ] +const C = 1 → const_declaration [ const_spec ] +``` + +The extractor filtered **direct** children for `var_spec` / `const_spec`, so a +grouped `var` block matched nothing. Knock-on effect: a call inside a +package-level grouped-var initializer (`var ( log = pkg.NewLogger("x") )`) +had **no source node**, so the edge could not be built at all. + +## 3. Design + +### 3.1 Data structures + +Added to `go-module.ts`. The existing `GoModule` / `loadGoModule` are left +untouched — they remain the single-module fast path and the backward-compat +shim. + +```ts +export interface GoModuleEntry { + modulePath: string; // the `module` directive + relDir: string; // dir holding this go.mod, relative to projectRoot, '/'-separated; '' for root +} + +export interface GoModuleIndex { + entries: GoModuleEntry[]; // sorted by modulePath length DESCENDING + resolve(importPath: string): { entry: GoModuleEntry; subPath: string } | null; + packageDir(importPath: string): string | null; // → project-relative package dir +} +``` + +`packageDir` is the core. Its contract: + +| relDir | modulePath | importPath | → packageDir | +|---|---|---|---| +| `commons` | `example.com/org/commons` | `example.com/org/commons/basic/errs` | `commons/basic/errs` | +| `commons` | `example.com/org/commons` | `example.com/org/commons` | `commons` | +| `''` (root module) | `example.com/app` | `example.com/app/pkga` | `pkga` | +| `''` | `example.com/app` | `example.com/app` | `''` | + +The last two rows reproduce the previous single-module algorithm exactly — +that is the backward-compatibility guarantee. Note `packageDir` can legally +return `''` (root module, root package), so callers must test `=== null`, +never falsiness. + +### 3.2 Scanning + +`loadGoModules(projectRoot)` walks the tree collecting every `go.mod`: + +- skips `node_modules` `.git` `vendor` `testdata` `dist` `build` `target` + `.venv` `.codegraph` +- **keeps descending after finding one** — Go permits nested modules +- caps at depth 8 / 1000 modules, and returns what it collected rather than + throwing +- directory names are sorted before traversal, so results do not depend on + filesystem ordering +- when one module path appears in several `go.mod` (a vendored or templated + copy), the shortest `relDir` wins, deterministically +- returns `null` — not an empty index — when the project has no `go.mod`, so + every downstream `if (!idx)` branch behaves exactly as before + +`resolve()` walks the length-descending entries and takes the first +`importPath === modulePath || importPath.startsWith(modulePath + '/')`. +Descending order is load-bearing: without it `example.com/org/platform` +would swallow imports belonging to `example.com/org/platform/sdk`. + +### 3.3 Consumption + +Three call sites prefer the multi-module index and fall back to the +single-module path verbatim: + +- `isExternalImport` (Go branch) — an import belonging to any local module is + in-project +- `resolveGoCrossPackageReference` — uses `packageDir()`; keeps the existing + exact-parent match (`fileDir === pkgDir`, never `startsWith`) so + `pkga.FuncX` cannot land on a `FuncX` in `pkga/subpkg/` +- the name-matcher's Go field-type guard — an anti-fabrication guard (#1276); + widened only to genuine local modules, not relaxed + +The index is loaded lazily and memoised per resolver, matching the existing +convention of `path-aliases.ts` and `workspace-packages.ts`. Like those two, +it is **resolver metadata only** — it produces no nodes and no edges. + +## 4. Compatibility + +Single-module repos with no nested modules are byte-identical to before. + +Single-module repos that *do* contain a nested module (e.g. a `tools/go.mod`) +change behaviour: references into that nested module now resolve through the +import path instead of falling back to name matching. That is a correctness +improvement, but it is a change, so it is called out here rather than +described as "identical". + +## 5. Verification + +**Reproducible** — 14 tests, all building real multi-module layouts in temp +directories and indexing them for real (no mocks): + +``` +npx vitest run __tests__/resolution.test.ts -t "Go multi-module" +npx vitest run __tests__/extraction.test.ts -t "Go const/var extraction" +``` + +Coverage: cross-module resolution; same-name symbol in a non-imported module +must not be picked; longest-prefix precedence; single-root-module +no-regress; no-`go.mod` no-op; sub-package exclusion; scan-depth cap; +grouped-var extraction; grouped-const no-regress; Go `isExported` across all +four const/var declaration forms; TypeScript/Python/Rust no-regress. + +**Scale check** — measured on a private 11.5k-file Go monorepo with 61 +side-by-side modules. Not reproducible outside that environment; reported for +magnitude only. The golden set is derived automatically from the source, with +no manual labelling: an import alias binds to exactly one module path, which +maps to exactly one local directory; a target defined exactly once in that +directory is an unambiguous ground truth. Anything ambiguous is discarded, so +the measurement is conservative. + +| | before | after | +|---|---|---| +| cross-module call recall | 4.03% | 99.67% | +| cross-module target precision | 6.83% | 100% | +| file coverage | 99.94% | 99.94% | +| call-site line precision | 99.20% | 99.73% | +| node count | 302,900 | 311,347 (+2.8%, grouped vars now indexed) | +| index time | ~65 s | ~67 s | + +## 6. Known gap (deliberately out of scope) + +Cross-module references to package-level **constants and variables** +(`alias.SomeConst` in value position) still do not resolve. The cause is +unrelated to anything above: value-position `alias.Symbol` references are +never *extracted* as references in the first place — `flushValueRefs` is +same-file only by design — so they never reach the resolver, regardless of +`isExported`. Fixing `isExported` (§2.2a) is still correct and necessary, but +it removes a guard nothing currently reaches. + +The real fix is an extraction-layer feature: emit an unresolved `references` +ref for a package-qualified selector in value position when the alias maps to +an imported package. That is tracked separately. + +Also unaddressed: module-level `imports` edges for Go. The module dependency +graph (which module requires which) is not materialised — consistent with +`path-aliases.ts` / `workspace-packages.ts`, module identity stays resolver +metadata rather than becoming graph nodes. diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 05b13a9c1..5adb25f0c 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -1776,6 +1776,7 @@ export class TreeSitterExtractor { const docstring = getPrecedingDocstring(node, this.source); const signature = this.extractor.getSignature?.(node, this.source); const visibility = this.extractor.getVisibility?.(node); + const isExported = this.extractor.isExported?.(node, this.source); const isAsync = this.extractor.isAsync?.(node); const isStatic = this.extractor.isStatic?.(node); const returnType = this.extractor.getReturnType?.(node, this.source); @@ -1783,6 +1784,7 @@ export class TreeSitterExtractor { docstring, signature, visibility, + isExported, isAsync, isStatic, returnType, @@ -2726,11 +2728,22 @@ export class TreeSitterExtractor { }); } } else if (this.language === 'go') { - // Go: var_declaration, short_var_declaration, const_declaration - // These can have multiple identifiers on the left - const specs = node.namedChildren.filter(c => - c.type === 'var_spec' || c.type === 'const_spec' - ); + // Go: var_declaration, short_var_declaration, const_declaration. + // Collect every var_spec/const_spec. A grouped `var ( … )` wraps its + // specs in a `var_spec_list` node, while a grouped `const ( … )` does + // NOT (its specs are direct children) — a tree-sitter-go grammar + // asymmetry. Filter only direct children and grouped var produces zero + // nodes, so flatten the *_spec_list wrapper too. + const specs: SyntaxNode[] = []; + for (const child of node.namedChildren) { + if (child.type === 'var_spec' || child.type === 'const_spec') { + specs.push(child); + } else if (child.type === 'var_spec_list' || child.type === 'const_spec_list') { + for (const inner of child.namedChildren) { + if (inner.type === 'var_spec' || inner.type === 'const_spec') specs.push(inner); + } + } + } for (const spec of specs) { const nameNode = spec.namedChild(0); @@ -2740,10 +2753,17 @@ export class TreeSitterExtractor { const valueNode = spec.namedChildCount > 1 ? spec.namedChild(spec.namedChildCount - 1) : null; const initValue = valueNode ? getNodeText(valueNode, this.source).slice(0, 100) : undefined; const initSignature = initValue ? `= ${initValue}${initValue.length >= 100 ? '...' : ''}` : undefined; + // Go export rule = leading uppercase. The isExported hook reads + // `getChildByField(node, 'name')`, so feed it the SPEC (whose `name` + // field IS the identifier) — NOT the declaration node (no `name` + // field → always false). Recompute per-spec here; do NOT touch the + // shared declaration-level isExported above (other languages use it). + const isExported = this.extractor.isExported?.(spec, this.source) ?? false; varNode = this.createNode(node.type === 'const_declaration' ? 'constant' : 'variable', name, spec, { docstring, signature: initSignature, + isExported, }); } // Walk the initializer so composite literals and calls in a diff --git a/src/resolution/go-module.ts b/src/resolution/go-module.ts index 03f6b9b23..0e9ce447f 100644 --- a/src/resolution/go-module.ts +++ b/src/resolution/go-module.ts @@ -23,9 +23,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. + * Only the project-root `go.mod` is read here. Nested / sibling `go.mod` + * files (Go workspaces, monorepos with multiple side-by-side modules) are + * resolved by {@link loadGoModules}, which builds an index over EVERY + * `go.mod` in the project and is the path used for cross-module resolution. + * This single-module reader is kept as a fast path + backward-compat shim. */ export function loadGoModule(projectRoot: string): GoModule | null { const goModPath = path.join(projectRoot, 'go.mod'); @@ -45,3 +47,141 @@ export function loadGoModule(projectRoot: string): GoModule | null { if (!modulePath) return null; return { modulePath, rootDir: projectRoot }; } + +/** A single `go.mod` entry discovered in the project tree. */ +export interface GoModuleEntry { + /** go.mod's `module` directive, e.g. `github.com/example/myorg/commons` */ + modulePath: string; + /** Directory containing this `go.mod`, relative to projectRoot, `/`-separated; `''` for the root module */ + relDir: string; +} + +/** + * Index over EVERY `go.mod` in a project (multi-module monorepo). Built once + * per resolution pass via {@link loadGoModules}. `null` when the project has + * no `go.mod` at all — callers then fall back to the single-module path. + * + * Entries are kept sorted by `modulePath` length DESCENDING so the longest + * prefix wins (`example.com/org/platform/sdk` must claim its own imports + * before `example.com/org/platform` shadows them). + */ +export interface GoModuleIndex { + /** All entries, sorted by `modulePath` length descending. */ + entries: GoModuleEntry[]; + /** Which local module an import belongs to, or `null` if none. */ + resolve(importPath: string): { entry: GoModuleEntry; subPath: string } | null; + /** Import path → project-relative package directory, or `null` if not local. */ + packageDir(importPath: string): string | null; +} + +/** Directories never descended into while scanning for `go.mod` files. */ +const GO_MOD_SKIP_DIRS = new Set([ + 'node_modules', '.git', 'vendor', 'testdata', 'dist', 'build', 'target', '.venv', '.codegraph', 'graphify-out', +]); +/** Safety net for pathological repos: stop descending past this depth. */ +const GO_MOD_MAX_DEPTH = 8; +/** Safety net: stop collecting after this many `go.mod` files. */ +const GO_MOD_MAX_FILES = 1000; + +/** + * Build a {@link GoModuleIndex} by recursively collecting every `go.mod` under + * `projectRoot`. Returns `null` only when NO `go.mod` is found, so a non-Go + * project stays on the same code path as before. See `docs/design/go-multi- + * module-resolution.md` §3 for the matching/normalization contract. + */ +export function loadGoModules(projectRoot: string): GoModuleIndex | null { + const byModulePath = new Map(); + + const scan = (dir: string, depth: number): void => { + // Collect the go.mod in THIS directory (if any), then keep descending — + // Go allows nested modules (the repro repo has 84 go.mod incl. nested + // tools modules), so finding one does NOT stop the descent. + const goModPath = path.join(dir, 'go.mod'); + let content: string | undefined; + try { + content = fs.readFileSync(goModPath, 'utf-8'); + } catch { + content = undefined; + } + if (content !== undefined) { + const modulePath = parseModuleDirective(content); + if (modulePath) { + const relDir = toRelDir(projectRoot, dir); + // A modulePath appearing in several go.mod (a vendored copy, a forked + // subtree): keep the entry with the SHORTEST relDir, deterministically. + // Determinism does NOT rely on traversal order — ties keep the incumbent. + const existing = byModulePath.get(modulePath); + if (!existing || relDir.length < existing.relDir.length) { + byModulePath.set(modulePath, { modulePath, relDir }); + } + } + // Below the file-count cap, stop collecting but keep scanning the tree + // (we still want the recursion bounds to hold, not throw). + if (byModulePath.size >= GO_MOD_MAX_FILES) return; + } + + if (depth >= GO_MOD_MAX_DEPTH) return; + + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + // Sort directory names so traversal (and thus any incidental tiebreak) is + // deterministic regardless of filesystem ordering. + const subdirs = entries + .filter((e) => e.isDirectory() && !GO_MOD_SKIP_DIRS.has(e.name)) + .map((e) => e.name) + .sort(); + for (const name of subdirs) { + scan(path.join(dir, name), depth + 1); + if (byModulePath.size >= GO_MOD_MAX_FILES) return; + } + }; + + scan(projectRoot, 0); + + if (byModulePath.size === 0) return null; + + const entries = [...byModulePath.values()].sort( + (a, b) => b.modulePath.length - a.modulePath.length || a.modulePath.localeCompare(b.modulePath) + ); + + const resolve = (importPath: string): { entry: GoModuleEntry; subPath: string } | null => { + for (const entry of entries) { + const mp = entry.modulePath; + if (importPath === mp) return { entry, subPath: '' }; + if (importPath.startsWith(mp + '/')) return { entry, subPath: importPath.substring(mp.length + 1) }; + } + return null; + }; + + const packageDir = (importPath: string): string | null => { + const r = resolve(importPath); + if (!r) return null; + const { entry, subPath } = r; + // relDir='' + subPath='' → '' (root module, root package) + // relDir='' → subPath (root module, subpackage) + // subPath='' → relDir (non-root module, root package) + // else → relDir + '/' + subPath + if (!entry.relDir) return subPath; + if (!subPath) return entry.relDir; + return entry.relDir + '/' + subPath; + }; + + return { entries, resolve, packageDir }; +} + +/** Strip line comments and parse the `module ` directive; `''` if absent. */ +function parseModuleDirective(content: string): string { + const stripped = content.replace(/\/\/[^\n]*/g, ''); + const match = stripped.match(/^\s*module\s+(\S+)\s*$/m); + if (!match) return ''; + return match[1]!.replace(/^["']|["']$/g, ''); +} + +/** `path.relative(projectRoot, dir)` normalized to forward slashes; root dir → `''`. */ +function toRelDir(projectRoot: string, dir: string): string { + return path.relative(projectRoot, dir).replace(/\\/g, '/'); +} diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts index 07f18cbb1..ecc02746a 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -347,8 +347,15 @@ function isExternalImport( if (importPath.startsWith('.')) { return false; } - // In-module imports look like `/sub/pkg` — local to - // this project. Without the module-path check we'd flag every + // Multi-module monorepo: an import that belongs to ANY local module is + // in-project. Without this, side-by-side modules in one tree all look + // like third-party packages to each other (issue #388 multi-module case). + const idx = context?.getGoModules?.(); + if (idx?.resolve(importPath)) { + return false; + } + // Single-module fast path / backward compat: in-module imports look like + // `/sub/pkg`. 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 + '/'))) { @@ -2016,8 +2023,9 @@ function resolveGoCrossPackageReference( imports: ImportMapping[], context: ResolutionContext ): ResolvedRef | null { + const idx = context.getGoModules?.(); const mod = context.getGoModule?.(); - if (!mod) return null; + if (!idx && !mod) return null; // Qualified call: receiver before `.`, member after. A bare reference // (no dot) is a same-file/in-package call — handled elsewhere. @@ -2029,13 +2037,22 @@ 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; + + // Project-relative package directory. The multi-module index handles a + // monorepo of side-by-side modules; when it's absent (single-module repo + // or pre-modules code) fall back to the original single-module algorithm + // verbatim, so behavior there is byte-identical to before. + let pkgDir: string | null = idx ? idx.packageDir(imp.source) : null; + if (pkgDir === null && mod) { + // Only in-module imports map to a known directory. + if (imp.source !== mod.modulePath && !imp.source.startsWith(mod.modulePath + '/')) { + continue; + } + pkgDir = imp.source === mod.modulePath + ? '' + : imp.source.substring(mod.modulePath.length + 1); } - const pkgDir = imp.source === mod.modulePath - ? '' - : imp.source.substring(mod.modulePath.length + 1); + if (pkgDir === null) continue; // 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 598e5e479..4ab5ad3fd 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -23,7 +23,7 @@ import { detectFrameworks } from './frameworks'; import { synthesizeCallbackEdges } from './callback-synthesizer'; import { createYielder, type MaybeYield } from './cooperative-yield'; import { loadProjectAliases, type AliasMap } from './path-aliases'; -import { loadGoModule, type GoModule } from './go-module'; +import { loadGoModule, type GoModule, loadGoModules, type GoModuleIndex } from './go-module'; import { loadWorkspacePackages, type WorkspacePackages } from './workspace-packages'; import { logDebug } from '../errors'; import type { ReExport } from './types'; @@ -277,6 +277,10 @@ export class ReferenceResolver { private projectAliases: AliasMap | null | undefined = undefined; // go.mod module path. Same lazy/immutable convention as projectAliases. private goModule: GoModule | null | undefined = undefined; + // Index over every go.mod in the project (multi-module monorepo). Same + // lazy/immutable convention. `getGoModules` is the preferred path for Go + // resolution; `goModule` stays as a single-module fast path / fallback. + private goModules: GoModuleIndex | null | undefined = undefined; // Monorepo workspace member packages. Same lazy/immutable convention. private workspacePackages: WorkspacePackages | null | undefined = undefined; @@ -657,6 +661,13 @@ export class ReferenceResolver { return this.goModule; }, + getGoModules: () => { + if (this.goModules === undefined) { + this.goModules = loadGoModules(this.projectRoot); + } + return this.goModules; + }, + getWorkspacePackages: () => { if (this.workspacePackages === undefined) { this.workspacePackages = loadWorkspacePackages(this.projectRoot); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 2a1fe0d82..50796c0c7 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -1957,14 +1957,22 @@ function matchGoFieldChainCall( // fabrication this matcher exists to prevent (#1276). if (rawType.includes('.')) { const pkg = rawType.split('.')[0]!; + // A package-qualified field type is followed only when the package is + // IN-MODULE. Multi-module index first (covers side-by-side modules in + // one tree); single-module path stays as the backward-compat fallback. + // This guard exists to PREVENT fabrication (#1276): stripping the + // qualifier and matching the bare name would conflate a stdlib / + // third-party type with any same-named project type. Keep it precise — + // only a package that genuinely belongs to a local module passes. + const idx = context.getGoModules?.(); 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 + '/')); + (!!idx?.resolve(imp.source) || + (!!mod && (imp.source === mod.modulePath || imp.source.startsWith(mod.modulePath + '/')))); 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 bc80e2fc7..83406660f 100644 --- a/src/resolution/types.ts +++ b/src/resolution/types.ts @@ -148,6 +148,13 @@ export interface ResolutionContext { * cross-package imports from third-party packages. */ getGoModule?(): import('./go-module').GoModule | null; + /** + * Index over **every** `go.mod` in the project (multi-module monorepo). + * Returns `null` when the project has no `go.mod` at all. Preferred over + * `getGoModule()` — `getGoModule()` is retained only as a single-module + * fast path and backward-compat fallback. + */ + getGoModules?(): import('./go-module').GoModuleIndex | null; /** * Monorepo workspace member packages, keyed by declared package name. * Returns `null` for single-package repos (no `workspaces` field).