Files
codegraph/__tests__/go-std-method-receivers.test.ts
T
Colby MchenryandClaude Opus 5.5 6c49bfa667 fix(go): a call through a variable declared with another package's type reaches that type's method (#2706)
Go receiver inference read a parameter's type only when the line
patterns could: a lowercase name of the caller's own package (`s
*server`) or a capitalized one (`lg Logger`). A parameter declared
through a package, like `client rest.Interface`, `c clock.PassiveClock`
or `t *testing.T`, had no type, so a call on it was matched by name:
kubernetes' `client.Patch()` reached `RESTClient::Patch` instead of the
interface, `c.Now()` an unrelated `Clock`, and every `t.Errorf(…)` a
test helper's `T` interface. Only standard method names (PR 2664) and
receivers named like an import (PR 2448) asked the scope reader.

inferLocalReceiverType now asks the scope reader first for every Go
receiver. The declaration in scope (a parameter, a result, a name
grouped with others, a `var (…)` spec, a function literal's parameter)
gives the named type it writes, as GoReceiverDecl.declared, ahead of
the patterns, which scan back with no block scope. matchMethodCall,
matchMemberFunctionRef and matchGoFieldChainCall all use it:
- a project type resolves in its own package, through embedding and
  aliases;
- a type from outside the project, a predeclared one or an alias of
  either (goHasNoProjectMethods) reaches none of the project's methods,
  and neither does a type literal;
- a qualifier none of the file's imports names keeps the old lookup by
  name.
Standard method names keep the rules PR 2664 gave them. Any other name
the declared project type lacks goes on to the name strategies, as on a
receiver of unread type. The block that typed only receivers named
like an import is subsumed and removed, with the field it read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:38:24 +00:00

338 lines
9.1 KiB
TypeScript

/**
* Go calls of a standard method name — `Error`, `String`, `Err`, `Lock`,
* `Header` — on a receiver nothing types.
*
* Every Go type may carry such a name, so the name alone says nothing of the
* receiver. A guess by a word the receiver shares with a type's name bound
* kubernetes' `err.Error()` to `notRegisteredErr::Error` and etcd's
* `ctx.Err()` to a `valCtx`. The receiver's declaration decides when it can be
* read — its declared type's own method, or nothing for a predeclared type
* or a type from outside the project — and otherwise only a receiver named
* after the owner type itself gets a guess.
*/
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';
let root = '';
let cg: CodeGraph;
beforeAll(async () => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-go-std-recv-'));
const files: Record<string, string> = {
'go.mod': 'module example.com/app\n\ngo 1.22\n',
'types/id.go': `package types
type ID uint64
func (i ID) String() string { return "" }
type URLsMap map[string][]string
func NewURLsMap() URLsMap { return URLsMap{} }
func (m URLsMap) String() string { return "" }
`,
'runtime/error.go': `package runtime
type notRegisteredErr struct{}
func (e *notRegisteredErr) Error() string { return "" }
type ParseErr struct{}
func (e *ParseErr) Error() string { return "" }
`,
'etcdserver/request.go': `package etcdserver
type MoveLeaderRequest struct{}
func (m *MoveLeaderRequest) String() string { return "" }
`,
'client/watch.go': `package client
import "context"
type valCtx struct{ context.Context }
func (c *valCtx) Err() error { return nil }
`,
'concurrency/mutex.go': `package concurrency
type Mutex struct{}
func (m *Mutex) Lock() error { return nil }
func (m *Mutex) Unlock() error { return nil }
`,
'chunks/iterator.go': `package chunks
type Iterator interface {
Next() bool
Err() error
}
`,
'index/postings.go': `package index
type StringIter interface {
Next() bool
Err() error
}
type symbolsIter struct{}
func (s *symbolsIter) Next() bool { return false }
func (s *symbolsIter) Err() error { return nil }
`,
'featuregate/fgtesting/tb.go': `package fgtesting
type TB interface {
Error(args ...any)
}
`,
'kubelet/runtime.go': `package kubelet
type Status struct{ Code int }
type RuntimeStatus struct{}
func (r *RuntimeStatus) String() string { return "" }
`,
'httpx/recorder.go': `package httpx
import "net/http"
type ResponseRecorder struct{}
func (r *ResponseRecorder) Header() http.Header { return nil }
`,
'boot/srv.go': `package boot
type Srv struct{}
func (s *Srv) Start() {}
`,
'promql/engine.go': `package promql
type Query interface {
Exec() string
String() string
}
func NewQuery(q string) Query { return nil }
`,
'server/version.go': `package server
type Version struct{}
func ParseVersion(s string) (*Version, error) { return &Version{}, nil }
func (v *Version) Major() string { return "" }
func (v *Version) String() string { return "" }
`,
'server/server.go': `package server
import (
"context"
"fmt"
"net/http/httptest"
"sync"
"testing"
"time"
"example.com/app/boot"
"example.com/app/chunks"
"example.com/app/promql"
"example.com/app/types"
)
type Server struct{}
func (s *Server) String() string { return "" }
func (s *Server) Describe() string { return s.String() }
func doWork() error { return nil }
func lookupLeader() fmt.Stringer { return nil }
func currentStatus() fmt.Stringer { return nil }
func newSrv() *boot.Srv { return nil }
func errorText(err error) string { return err.Error() }
func workText() string {
err := doWork()
return err.Error()
}
func leaderText() string {
leaderID := lookupLeader()
return leaderID.String()
}
func memberText(memberID types.ID) string { return memberID.String() }
func idsText(ids []types.ID) string {
out := ""
for _, id := range ids {
out += id.String()
}
return out
}
func ctxErr(ctx context.Context) error { return ctx.Err() }
func timeoutErr() error {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
return ctx.Err()
}
func locked() {
var mu sync.Mutex
mu.Lock()
}
func iterErr(iter chunks.Iterator) error { return iter.Err() }
func report(tb testing.TB) { tb.Error("failed") }
func statusText() string {
status := currentStatus()
return status.String()
}
func headers() {
recorder := httptest.NewRecorder()
_ = recorder.Header()
}
func urls() string {
urlsMap := types.NewURLsMap()
return urlsMap.String()
}
func startSrv() {
srv := newSrv()
srv.Start()
}
func runQueries() {
for _, tc := range []struct {
query string
}{{query: "up"}} {
func() {
var (
query promql.Query
)
query = promql.NewQuery(tc.query)
_ = query.Exec()
_ = query.String()
}()
}
}
func versionText() string {
tests := []struct {
version string
}{{version: "1.0"}}
out := ""
for _, test := range tests {
version, _ := ParseVersion(test.version)
out += version.Major() + version.String()
}
return out
}
`,
};
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);
}
cg = await CodeGraph.init(root, { index: true });
}, 120_000);
afterAll(() => {
cg?.close();
if (root) fs.rmSync(root, { recursive: true, force: true });
});
/** `file::qualifiedName resolvedBy@confidence` of every method the function `name` in server.go calls. */
function calls(name: string): string[] {
const fn = cg.getNodesInFile('server/server.go').find((n) => n.name === name && n.kind !== 'method');
expect(fn, name).toBeDefined();
return cg
.getOutgoingEdges(fn!.id)
.filter((e) => e.kind === 'calls')
.map((e) => ({ e, n: cg.getNode(e.target)! }))
.filter(({ n }) => n.kind === 'method')
.map(({ e, n }) => `${n.filePath.replace(/\\/g, '/')}::${n.qualifiedName} ${String(e.metadata?.resolvedBy)}@${String(e.metadata?.confidence)}`)
.sort();
}
describe('Go standard method names on a receiver nothing types', () => {
it.each([
// `func errorText(err error)`: the error interface's own method.
['errorText', 'a receiver of a predeclared type'],
// `err := doWork()`: an `err` is no `notRegisteredErr`.
['workText', 'a receiver sharing only a word with the owner type'],
// `leaderID := lookupLeader()`: no `MoveLeaderRequest`.
['leaderText', 'a receiver sharing only a word with the owner type'],
['ctxErr', 'a parameter of a type from outside the project'],
// `ctx, cancel := context.WithTimeout(…)`
['timeoutErr', 'a value a package from outside the project hands out'],
// `var mu sync.Mutex`: no project `Mutex`.
['locked', 'a variable of a type from outside the project'],
// `tb testing.TB`, beside a project interface `TB`.
['report', 'a parameter of a type from outside the project'],
// `recorder := httptest.NewRecorder()`
['headers', 'a value a package from outside the project hands out'],
// `status := currentStatus()`, beside a `Status` type and a `RuntimeStatus::String`.
['statusText', 'a receiver named after a type that lacks the method'],
])('links nothing from %s: %s', (fn) => {
expect(calls(fn)).toEqual([]);
});
it.each([
['memberText', 'types/id.go::ID::String instance-method@0.9'],
// Not `StringIter::Err` or `symbolsIter::Err`, by the word "iter".
['iterErr', 'chunks/iterator.go::Iterator::Err instance-method@0.9'],
])('links %s through the type its parameter is declared as', (fn, target) => {
expect(calls(fn)).toEqual([target]);
});
it.each([
// `for _, id := range ids`
['idsText', 'types/id.go::ID::String instance-method@0.65'],
// `urlsMap := types.NewURLsMap()`
['urls', 'types/id.go::URLsMap::String instance-method@0.65'],
])('still guesses for %s through a receiver named after the owner type', (fn, target) => {
expect(calls(fn)).toEqual([target]);
});
it.each([
// `var ( query promql.Query )`, read past the table's `query string`;
// `Exec`, no standard name, is typed by that declaration too.
['runQueries', ['promql/engine.go::Query::Exec instance-method@0.9', 'promql/engine.go::Query::String instance-method@0.9']],
// `version, _ := ParseVersion(…)` says no type, and the table's
// `version string` is another declaration: nothing is ruled out.
['versionText', ['server/version.go::Version::Major instance-method@0.8', 'server/version.go::Version::String instance-method@0.8']],
])('reads %s by the declaration in scope, not a test table field of the name', (fn, targets) => {
expect(calls(fn)).toEqual(targets);
});
it("leaves typed receivers and other method names as they were", () => {
const describeFn = cg.getNodesInFile('server/server.go').find((n) => n.qualifiedName === 'Server::Describe');
expect(describeFn).toBeDefined();
const targets = cg.getOutgoingEdges(describeFn!.id).filter((e) => e.kind === 'calls').map((e) => cg.getNode(e.target)!.qualifiedName);
expect(targets).toEqual(['Server::String']);
expect(calls('startSrv').filter((c) => c.includes('Srv::Start'))).toHaveLength(1);
});
});