Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Fixes

- In Go, calls between the packages of a module whose `go.mod` sits in a subdirectory instead of the project root, like a `server/` backend next to a `web/` frontend or several modules side by side, now resolve, so a package-qualified call such as `store.New()` or a call through a struct field such as `s.db.CreateItem()` finds its target; re-index after upgrading. Thanks @GoDiao for the report. (#2322)

## [1.6.2] - 2026-10-03

Expand Down
233 changes: 233 additions & 0 deletions __tests__/go-nested-module.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
/**
* Go modules whose `go.mod` is below the project root (#2322).
*
* A Go backend kept next to a frontend (`server/go.mod`, `web/package.json`)
* or several modules side by side: each module's import paths start with its
* own module path, and an in-module import names a directory under that
* module's root. Only the project-root `go.mod` used to be read, so with the
* module in `svc/` both `store.New()` and `s.db.CreateItem()` lost their
* callers. A root-level `go.mod` resolves exactly as before.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';

const STORE = `package store

type Manager struct{}

func New() *Manager { return &Manager{} }

func (m *Manager) CreateItem(name string) error { return nil }
`;

const CLOCK = `package clock

func Now() int { return 0 }
`;

// The issue's service, plus a call into a package outside \`internal/\`.
const service = (mod: string) => `package domain

import (
"${mod}/internal/store"
"${mod}/pkg/clock"
)

type Service struct {
db *store.Manager
}

func NewService() *Service {
return &Service{db: store.New()}
}

func (s *Service) AddItem(name string) error {
return s.db.CreateItem(name)
}

func Stamp() int {
return clock.Now()
}
`;

const projects: Array<{ root: string; cg: CodeGraph }> = [];

async function indexProject(files: Record<string, string>): Promise<CodeGraph> {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-go-nested-mod-'));
for (const [rel, content] of Object.entries(files)) {
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
fs.writeFileSync(path.join(root, rel), content);
}
const cg = await CodeGraph.init(root, { index: true });
projects.push({ root, cg });
return cg;
}

afterAll(() => {
for (const { root, cg } of projects) {
cg.close();
fs.rmSync(root, { recursive: true, force: true });
}
});

/** `file::qualifiedName` of every symbol the function `name` in `file` calls. */
function callTargets(cg: CodeGraph, file: string, name: string): string[] {
const fn = cg.getNodesInFile(file).find((n) => n.name === name);
expect(fn, `${name} in ${file}`).toBeDefined();
return cg
.getOutgoingEdges(fn!.id)
.filter((e) => e.kind === 'calls')
.map((e) => cg.getNode(e.target)!)
.map((n) => `${n.filePath.replace(/\\/g, '/')}::${n.qualifiedName}`)
.sort();
}

describe('Go module in a subdirectory (#2322)', () => {
let cg: CodeGraph;
beforeAll(async () => {
cg = await indexProject({
'svc/go.mod': 'module example.com/app/svc\n\ngo 1.22\n',
'svc/internal/store/store.go': STORE,
'svc/pkg/clock/clock.go': CLOCK,
'svc/internal/domain/service.go': service('example.com/app/svc'),
'web/package.json': '{ "name": "web" }\n',
});
});

it('resolves a package-qualified call into the module', () => {
expect(callTargets(cg, 'svc/internal/domain/service.go', 'NewService')).toEqual([
'svc/internal/store/store.go::New',
]);
expect(callTargets(cg, 'svc/internal/domain/service.go', 'Stamp')).toEqual([
'svc/pkg/clock/clock.go::Now',
]);
});

it('resolves a call through a struct field typed with a package of the module', () => {
expect(callTargets(cg, 'svc/internal/domain/service.go', 'AddItem')).toEqual([
'svc/internal/store/store.go::Manager::CreateItem',
]);
});
});

describe('Go modules side by side (#2322)', () => {
let cg: CodeGraph;
beforeAll(async () => {
cg = await indexProject({
'server/go.mod': 'module example.com/server\n\ngo 1.22\n',
'server/api/api.go': 'package api\n\nfunc Start() int { return 1 }\n',
'server/cmd/main.go': `package main

import (
"example.com/server/api"
"example.com/tools/lint"
)

type App struct {
linter *lint.Linter
}

func main() {
api.Start()
lint.Run()
}

func (a *App) check() {
a.linter.Check()
}
`,
'server/cmd/ext.go': `package main

import (
"example.com/toolsx/api"
)

func external() {
api.Start()
}
`,
'tools/go.mod': 'module example.com/tools\n\ngo 1.22\n',
'tools/api/api.go': 'package api\n\nfunc Start() int { return 2 }\n',
'tools/lint/lint.go': `package lint

type Linter struct{}

func Run() {}

func (l *Linter) Check() {}
`,
'tools/gen/gen.go': `package main

import "example.com/tools/api"

func generate() {
api.Start()
}
`,
});
});

it("resolves each module's import into that module's own package", () => {
expect(callTargets(cg, 'tools/gen/gen.go', 'generate')).toEqual(['tools/api/api.go::Start']);
expect(callTargets(cg, 'server/cmd/main.go', 'main')).toEqual([
'server/api/api.go::Start',
'tools/lint/lint.go::Run',
]);
});

it('follows a struct field typed with the other module\'s package', () => {
expect(callTargets(cg, 'server/cmd/main.go', 'check')).toEqual(['tools/lint/lint.go::Linter::Check']);
});

it('leaves an import that only shares a prefix with a module path unresolved', () => {
expect(callTargets(cg, 'server/cmd/ext.go', 'external')).toEqual([]);
});
});

describe('Two Go modules declaring the same path (#2322)', () => {
let cg: CodeGraph;
beforeAll(async () => {
const files: Record<string, string> = {};
for (const copy of ['v1', 'v2']) {
files[`${copy}/go.mod`] = 'module example.com/app/svc\n\ngo 1.22\n';
files[`${copy}/internal/store/store.go`] = STORE;
files[`${copy}/pkg/clock/clock.go`] = CLOCK;
files[`${copy}/internal/domain/service.go`] = service('example.com/app/svc');
}
cg = await indexProject(files);
});

it("resolves each copy's imports into its own module", () => {
for (const copy of ['v1', 'v2']) {
expect(callTargets(cg, `${copy}/internal/domain/service.go`, 'NewService')).toEqual([
`${copy}/internal/store/store.go::New`,
]);
expect(callTargets(cg, `${copy}/internal/domain/service.go`, 'Stamp')).toEqual([
`${copy}/pkg/clock/clock.go::Now`,
]);
}
});
});

describe('Go module at the project root', () => {
let cg: CodeGraph;
beforeAll(async () => {
cg = await indexProject({
'go.mod': 'module example.com/app/svc\n\ngo 1.22\n',
'internal/store/store.go': STORE,
'pkg/clock/clock.go': CLOCK,
'internal/domain/service.go': service('example.com/app/svc'),
});
});

it('resolves exactly as before', () => {
expect(callTargets(cg, 'internal/domain/service.go', 'NewService')).toEqual(['internal/store/store.go::New']);
expect(callTargets(cg, 'internal/domain/service.go', 'Stamp')).toEqual(['pkg/clock/clock.go::Now']);
expect(callTargets(cg, 'internal/domain/service.go', 'AddItem')).toEqual([
'internal/store/store.go::Manager::CreateItem',
]);
});
});
2 changes: 1 addition & 1 deletion src/extraction/extraction-version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,4 @@
* turns the re-index hint into noise — keep it honest (see CLAUDE.md, "Honesty
* in the product is load-bearing").
*/
export const EXTRACTION_VERSION = 27;
export const EXTRACTION_VERSION = 28;
37 changes: 28 additions & 9 deletions src/resolution/go-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,13 @@ export interface GoModule {
}

/**
* Read the `go.mod` file at the project root and extract the module path.
* Returns `null` if no `go.mod` exists or it has no `module` directive.
*
* Limitation: only the project-root `go.mod` is read. Nested `go.mod` files
* (Go workspaces, monorepos with multiple modules) are not yet resolved —
* a follow-up if a real repro shows up.
* Read the `go.mod` file in `moduleDir` and extract the module path.
* Returns `null` if no `go.mod` exists there or it has no `module` directive.
* The resolver calls it for every directory between a Go file and the project
* root, so a module whose `go.mod` sits below the root resolves too (#2322).
*/
export function loadGoModule(projectRoot: string): GoModule | null {
const goModPath = path.join(projectRoot, 'go.mod');
export function loadGoModule(moduleDir: string): GoModule | null {
const goModPath = path.join(moduleDir, 'go.mod');
let content: string;
try {
content = fs.readFileSync(goModPath, 'utf-8');
Expand All @@ -43,5 +41,26 @@ export function loadGoModule(projectRoot: string): GoModule | null {
// Strip optional quoting around the module path.
const modulePath = match[1]!.replace(/^["']|["']$/g, '');
if (!modulePath) return null;
return { modulePath, rootDir: projectRoot };
return { modulePath, rootDir: moduleDir };
}

/**
* The module an import path belongs to: the one whose module path equals it or
* is a `/`-bounded prefix of it. Nested modules (`example.com/app` and
* `example.com/app/tools`) take the longest module path, as Go does; two
* modules declaring the same path prefer `own`, the importing file's module.
* `null` for the standard library and third-party modules.
*/
export function findGoModuleForImport(
importPath: string,
modules: readonly GoModule[],
own?: GoModule | null
): GoModule | null {
let best: GoModule | null = null;
for (const mod of modules) {
if (importPath !== mod.modulePath && !importPath.startsWith(`${mod.modulePath}/`)) continue;
const length = best ? best.modulePath.length : -1;
if (mod.modulePath.length > length || (mod.modulePath.length === length && mod === own)) best = mod;
}
return best;
}
19 changes: 9 additions & 10 deletions src/resolution/import-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -465,8 +465,7 @@ export function isExternalImport(
// 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 + '/'))) {
if (context?.getGoModuleForImport?.(importPath)) {
return false;
}
// `internal/` packages stay local even when go.mod is missing —
Expand Down Expand Up @@ -2481,9 +2480,6 @@ function resolveGoCrossPackageReference(
imports: ImportMapping[],
context: ResolutionContext
): ResolvedRef | null {
const mod = context.getGoModule?.();
if (!mod) return null;

// Qualified call: receiver before `.`, member after. A bare reference
// (no dot) is a same-file/in-package call — handled elsewhere.
const dotIdx = ref.referenceName.indexOf('.');
Expand All @@ -2494,13 +2490,16 @@ function resolveGoCrossPackageReference(

for (const imp of imports) {
if (imp.localName !== receiver) continue;
// Only in-module imports map to a known directory.
if (imp.source !== mod.modulePath && !imp.source.startsWith(mod.modulePath + '/')) {
continue;
}
const pkgDir = imp.source === mod.modulePath
// Only in-module imports map to a known directory: the module's own
// directory (the project root, or wherever its go.mod sits — #2322)
// followed by the rest of the import path.
const mod = context.getGoModuleForImport?.(imp.source, ref.filePath);
if (!mod) continue;
const modDir = path.relative(context.getProjectRoot(), mod.rootDir).replace(/\\/g, '/');
const subDir = imp.source === mod.modulePath
? ''
: imp.source.substring(mod.modulePath.length + 1);
const pkgDir = modDir && subDir ? `${modDir}/${subDir}` : modDir || subDir;

// Look up the member by name and pick the candidate whose file lives
// directly in the package directory. Match the immediate parent dir
Expand Down
Loading