mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-16 20:53:57 +08:00
deac7710f2
Calls through a method receiver or parameter of an unexported type
(`func (s *server) Create() { s.service.AddItem() }`) had no edge: the
Go receiver inference only accepted PascalCase types (#2323). And only
the project-root go.mod was read, so a module in a subdirectory, or one
beside other modules as in etcd, treated imports of itself as
third-party (#2322).
Fixing either alone sends calls to the wrong package, because Go type
names are unique only per directory while types and methods were looked
up by name across the project. The receiver pattern alone sent 1,042 of
harbor's 1,998 new calls to another package's same-named type; reading
nested modules alone made harbor's caching wrappers call themselves and
put about 180 of etcd's type references on same-named methods. Both
share one fix:
- A lowercase receiver or parameter type is read only where a parameter
list puts it (receivers, parameters, multi-line lists, var blocks).
- Receiver inference also reports the type as written. For Go,
resolveMethodOnType takes the declaring package's directory (the
caller's for a bare name, the import's for a qualified one) and counts
only that package's methods, then the types its declaration embeds
(struct and interface embedding, read from the source) instead of the
name-based supertype union. The field-chain matcher scopes its struct
and field types the same way.
- The resolver reads the nearest go.mod of every directory holding
indexed Go files (no disk walk; a module under testdata/ or a _/.
directory serves only its own files) and maps an import path to the
module with the longest module path, the importing file's own module
breaking a tie. getGoPackageDir replaces getGoModule.
- A bare name written through a package (`job.OPCommand`) resolves
through the import into that package's directory, and a name-matched
candidate outside the qualifier's package is rejected; a method reached
through a variable that shadows an import is exempt.
Verification: new go-unexported-receiver (3) and go-nested-module (11)
tests: 12 fail on main (the 2 controls pass), all 14 pass. The issues'
repros go from 0 to 1 caller; the root-go.mod and `Server` variants are
unchanged.
Validation, main 26e8488a -> this branch, whole repos, Go->Go edges keyed
by source/target qualified name, file, line and column:
- etcd 6bb7e5e: 52,530 -> 66,284 (+13,827 / -73), calls 18,074 -> 25,265
- harbor 721c6d4: 62,423 -> 77,615 (+15,819 / -627), calls 16,308 -> 25,236
- prometheus 770ca8f: 90,970 -> 92,399 (+2,249 / -820), calls 40,050 -> 41,493
Of the 1,520 removed edges, 1,458 are the same call site or reference
now resolved to the right package's symbol; the other 62 were field
reads taken for method values, names whose real target is not indexed
(a `type T = string` alias), and a call to a method the receiver's type
does not have. Indexing time and CPU are unchanged.
Co-authored-by: Diao Shengjia <104132148+GoDiao@users.noreply.github.com>
Co-authored-by: danusha2345 <ewidusoc498@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
332 lines
9.8 KiB
TypeScript
332 lines
9.8 KiB
TypeScript
/**
|
||
* 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<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 `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<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(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',
|
||
]);
|
||
});
|
||
});
|