Files
codegraph/__tests__/go-stdlib-named-locals.test.ts
Colby MchenryandClaude Opus 5.5 8a5ba3d02c fix(go): a call through a parameter or local named like a standard-library package reaches the project's method (#2489)
isBuiltInOrExternal dropped every dotted Go ref whose first segment is in
GO_STDLIB_PACKAGES before any resolution, so a call through a parameter or
local of that name linked to nothing: gin's `context.AbortWithError(…)` in
`func(context *Context)`, harbor's `context.IsAuthenticated()` after
`context := NewSecurityContext(…)` and `log.Errorf(…)` after
`log := log.G(ctx)`, etcd's `printer.DBHashKV(…)` and `trace.Step(…)`.

Such a call now gets past the package list when #2448's scope reader finds
the name declared in the function around it, unless the declaration says it
holds no project method: a type from outside the project or a predeclared
one, or what an outside package's function hands out
(`scanner := bufio.NewScanner(r)`, `url, err := url.Parse(raw)`). #2478's
exemption for locals bound from a type assertion stays beside it.

matchMethodCall resolves these calls by the declaration alone, never by the
receiver's name, which fits dozens of a big tree's types (kubernetes'
`printer := NewTablePrinter(…)` is no kubeadm `Printer`):
- a written or asserted project type (goWrittenType) calls its own method,
  an interface's or a promoted one, or nothing;
- a value a project function hands out calls a method of that function's
  package: the type named like the variable, or the only one of the name;
- anything else calls only a method no other type declares, never one with
  a standard-library method name.
The scope reader also records `x := T{…}` / `new(T)` types and the
own-package function a `:=` calls. GO_STDLIB_PACKAGES moves to name-matcher.

A/B against main 1d3619d6, natural-key edge diffs with identical nodes and
no edge removed: gin +2, prometheus 0, etcd +44, harbor +60, kubernetes
+508, every one triaged correct. The first version, which let these calls
reach name matching, added 811 on kubernetes, 346 of them guesses by the
receiver's name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:50:55 +00:00

495 lines
14 KiB
TypeScript

/**
* A dotted Go reference whose receiver is named like a standard-library
* package (`context`, `url`, `user`, `scanner`, `printer`, `log`, …) is taken
* for a call into that package and dropped before resolution. A parameter or
* local can carry the name too, and a call through it is a method call on
* that variable: gin's `router.Use(func(context *Context) { context.Next() })`,
* harbor's `context := NewSecurityContext(…)` then
* `context.IsAuthenticated()`, etcd's `printer.DBHashKV(…)`.
*
* Such a call resolves by what the variable's declaration says, never by its
* name, which fits many of a big tree's types: the type it is written as, the
* package of the project function its value comes from, or a method name no
* other type declares. A variable holding a value of a package from outside
* the project (`scanner := bufio.NewScanner(r)`, `url, err := url.Parse(raw)`,
* a `context *gin.Context` parameter) calls none of the project's methods.
*/
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 files: Record<string, string> = {
'go.mod': 'module example.com/app\n\ngo 1.22\n',
// gin's own Context, called from its tests through a parameter named `context`.
'gin/context.go': `package gin
type Context struct{}
func (c *Context) AbortWithError(code int, err error) error { return err }
func (c *Context) Next() {}
type Engine struct{}
func New() *Engine { return &Engine{} }
func (engine *Engine) Use(middleware func(*Context)) {}
`,
'gin/middleware_test.go': `package gin
import (
"errors"
"net/http"
"testing"
)
func TestMiddlewareFailHandlersChain(t *testing.T) {
router := New()
router.Use(func(context *Context) {
context.AbortWithError(http.StatusInternalServerError, errors.New("foo"))
})
router.Use(func(context *Context) {
context.Next()
})
}
`,
// harbor's security contexts: an interface, and two packages' implementations.
'security/context.go': `package security
type Context interface {
IsAuthenticated() bool
}
`,
'secret/context.go': `package secret
type SecurityContext struct{ secret string }
func NewSecurityContext(secret string, store any) *SecurityContext {
return &SecurityContext{secret: secret}
}
func (s *SecurityContext) IsAuthenticated() bool { return s.secret != "" }
`,
'local/context.go': `package local
type SecurityContext struct{ user string }
func (s *SecurityContext) IsAuthenticated() bool { return s.user != "" }
`,
'secret/context_test.go': `package secret
import "testing"
func TestIsAuthenticated(t *testing.T) {
context := NewSecurityContext("", nil)
if context.IsAuthenticated() {
t.Fail()
}
}
`,
// Project types with the method names of the standard library's and gin's
// types: a call on one of those values must not land here.
'textutil/types.go': `package textutil
type Scanner struct{}
func (s *Scanner) Scan() bool { return false }
func (s *Scanner) Text() string { return "" }
type URL struct{}
func (u *URL) Hostname() string { return "" }
type Context struct{}
func (c *Context) JSON(code int, obj any) {}
`,
'textutil/read.go': `package textutil
import (
"bufio"
"io"
"net/url"
"github.com/gin-gonic/gin"
)
func FirstLine(r io.Reader) string {
scanner := bufio.NewScanner(r)
if scanner.Scan() {
return scanner.Text()
}
return ""
}
func Host(raw string) string {
url, err := url.Parse(raw)
if err != nil {
return ""
}
return url.Hostname()
}
func Handle(context *gin.Context) {
context.JSON(200, nil)
}
`,
// A parameter declared as a project package's type, which another package
// shares the name of.
'models/user.go': `package models
type User struct{ Name string }
func (u *User) Validate() error { return nil }
`,
'legacy/user.go': `package legacy
type User struct{}
func (u *User) Validate() error { return nil }
func (u *User) Name() string { return "" }
`,
'api/users.go': `package api
import (
"context"
"strings"
"example.com/app/models"
)
func Save(user *models.User) error {
return user.Validate()
}
func Label(user *models.User) string {
name := user.Name
return name
}
// Named like the standard library's functions.
func Background() {}
func TrimSpace(s string) string { return s }
func Run() string {
ctx := context.Background()
_ = ctx
return strings.TrimSpace(" x ")
}
`,
// harbor's lib/log: a project package named like the standard library's,
// whose logger a local takes the name of.
'lib/log/log.go': `package log
type Logger struct{}
func G(ctx any) *Logger { return &Logger{} }
func (l *Logger) Errorf(format string, v ...any) {}
func Errorf(format string, v ...any) {}
`,
'quota/util.go': `package quota
import (
"context"
"example.com/app/lib/log"
)
func Refresh(ctx context.Context) {
log := log.G(ctx)
log.Errorf("refresh quota: %v", ctx)
}
`,
// kubernetes' post-start hooks: the hook context gets Done from the
// standard library's context it embeds, not from a project type.
'wait/wait.go': `package wait
type channelContext struct{ stopCh <-chan struct{} }
func (c channelContext) Done() <-chan struct{} { return c.stopCh }
`,
'apiserver/hooks.go': `package apiserver
import "context"
type PostStartHookContext struct {
context.Context
LoopbackClientConfig string
}
func AddPostStartHook(name string, hook func(context PostStartHookContext) error) {}
func register() {
AddPostStartHook("start-informers", func(context PostStartHookContext) error {
<-context.Done()
return nil
})
}
`,
// kubernetes' cli-runtime printers, and kubeadm's unrelated Printer.
'printers/printers.go': `package printers
type ResourcePrinter interface {
PrintObj(obj any) error
}
type HumanReadablePrinter struct{}
func (h *HumanReadablePrinter) PrintObj(obj any) error { return nil }
type JSONPrinter struct{}
func (p *JSONPrinter) PrintObj(obj any) error { return nil }
func NewTablePrinter() ResourcePrinter { return &HumanReadablePrinter{} }
`,
'printers/tableprinter_test.go': `package printers
import "testing"
func TestPrintTable(t *testing.T) {
printer := NewTablePrinter()
_ = printer.PrintObj(nil)
}
`,
'kubeadm/output/output.go': `package output
type Printer interface {
PrintObj(obj any) error
}
`,
// etcd's etcdutl printers: the variable is the interface its type is named.
'etcdutl/printer.go': `package etcdutl
type printer interface {
DBHashKV(hash uint32)
}
type simplePrinter struct{}
func (s *simplePrinter) DBHashKV(hash uint32) {}
type jsonPrinter struct{}
func (j *jsonPrinter) DBHashKV(hash uint32) {}
func initPrinterFromCmd(format string) printer {
if format == "json" {
return &jsonPrinter{}
}
return &simplePrinter{}
}
func hashKVCommandFunc(format string) {
printer := initPrinterFromCmd(format)
printer.DBHashKV(1)
}
`,
// etcd's traceutil, and a namesake Trace in another package.
'traceutil/trace.go': `package traceutil
type Trace struct{}
func Get(ctx any) *Trace { return &Trace{} }
func (t *Trace) Step(msg string) {}
`,
'otherutil/trace.go': `package otherutil
type Trace struct{}
func (t *Trace) Step(msg string) {}
`,
'txn/range.go': `package txn
import "example.com/app/traceutil"
func executeRange(ctx any) {
trace := traceutil.Get(ctx)
trace.Step("range")
}
`,
// etcd's grpc proxy: a function of its own package hands out another
// package's TLSInfo.
'transport/tls.go': `package transport
type TLSInfo struct{ CAFile string }
func (info TLSInfo) ClientConfig() string { return info.CAFile }
`,
'etcdmain/grpc_proxy.go': `package etcdmain
import "example.com/app/transport"
func newTLS(ca string) *transport.TLSInfo { return &transport.TLSInfo{CAFile: ca} }
func newClientCfg(ca string) string {
tls := newTLS(ca)
return tls.ClientConfig()
}
`,
// Range variables: nothing says what they hold.
'plugins/plugins.go': `package plugins
type Plugin interface {
Name() string
}
type volumePlugin struct{}
func (v *volumePlugin) Name() string { return "volume" }
func (v *volumePlugin) CanSupportAttach() bool { return true }
func names(all []Plugin) []string {
var out []string
for _, plugin := range all {
out = append(out, plugin.Name())
}
return out
}
func attachable(all []*volumePlugin) int {
n := 0
for _, plugin := range all {
if plugin.CanSupportAttach() {
n++
}
}
return n
}
`,
};
let root = '';
let cg: CodeGraph;
beforeAll(async () => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-go-stdlib-locals-'));
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 });
}, 60_000);
afterAll(() => {
cg?.close();
if (root) fs.rmSync(root, { recursive: true, force: true });
});
/** The line of `file` that holds `snippet`. */
const lineOf = (file: string, snippet: string): number => {
const at = files[file]!.split('\n').findIndex((l) => l.includes(snippet));
if (at < 0) throw new Error(`no ${snippet} in ${file}`);
return at + 1;
};
/** What the references on the line of `file` holding `snippet` reach, as `file:qualifiedName`. */
const reachedAt = (file: string, snippet: string): string[] => {
const line = lineOf(file, snippet);
const ids = cg.getNodesInFile(file).map((n) => n.id);
return cg.getOutgoingEdgesFrom(ids)
.filter((e) => e.kind !== 'contains' && e.line === line)
.map((e) => {
const target = cg.getNode(e.target)!;
return `${target.filePath}:${target.qualifiedName}`;
});
};
describe('a Go parameter or local named like a standard-library package', () => {
it('calls the methods of the project type it is declared as', () => {
expect(reachedAt('gin/middleware_test.go', 'context.AbortWithError(')).toEqual(['gin/context.go:Context::AbortWithError']);
expect(reachedAt('gin/middleware_test.go', 'context.Next()')).toEqual(['gin/context.go:Context::Next']);
// Not legacy's `User`, which shares the name.
expect(reachedAt('api/users.go', 'user.Validate()')).toEqual(['models/user.go:User::Validate']);
});
it("calls nothing the project declares when its type gets the method from outside", () => {
// `Done` comes from the `context.Context` the hook context embeds: not
// the project's `channelContext`, which a receiver word would name.
expect(reachedAt('apiserver/hooks.go', 'context.Done()')).toEqual([]);
});
it('calls a method of the package of the project function its value comes from', () => {
// harbor: secret's own, not local's namesake or the security.Context interface.
expect(reachedAt('secret/context_test.go', 'context.IsAuthenticated()')).toEqual(['secret/context.go:SecurityContext::IsAuthenticated']);
// etcd: traceutil's Trace, not another package's.
expect(reachedAt('txn/range.go', 'trace.Step(')).toEqual(['traceutil/trace.go:Trace::Step']);
// A logger a project package named like the standard library's hands out:
// the Logger's method, not the package's function.
expect(reachedAt('quota/util.go', 'log.Errorf(')).toEqual(['lib/log/log.go:Logger::Errorf']);
// A package that declares no method of the name: one no other type declares.
expect(reachedAt('etcdmain/grpc_proxy.go', 'tls.ClientConfig()')).toEqual(['transport/tls.go:TLSInfo::ClientConfig']);
});
it('takes the type named like the variable among that package\'s', () => {
expect(reachedAt('etcdutl/printer.go', 'printer.DBHashKV(1)')).toEqual(['etcdutl/printer.go:printer::DBHashKV']);
});
it('guesses no type by its name', () => {
// Two `PrintObj`s in printers, neither a `printer`, and kubeadm's unrelated `Printer`.
expect(reachedAt('printers/tableprinter_test.go', 'printer.PrintObj(nil)')).toEqual([]);
// A range variable calls only a method no other type declares.
expect(reachedAt('plugins/plugins.go', 'plugin.Name()')).toEqual([]);
expect(reachedAt('plugins/plugins.go', 'plugin.CanSupportAttach()')).toEqual(['plugins/plugins.go:volumePlugin::CanSupportAttach']);
});
it('holds no project method when its value comes from outside the project', () => {
expect(reachedAt('textutil/read.go', 'if scanner.Scan()')).toEqual([]);
expect(reachedAt('textutil/read.go', 'return scanner.Text()')).toEqual([]);
expect(reachedAt('textutil/read.go', 'url.Hostname()')).toEqual([]);
expect(reachedAt('textutil/read.go', 'context.JSON(')).toEqual([]);
});
it('is read as a value, not a method, where it is not called', () => {
// `user.Name` is the field, not legacy's `Name` method.
expect(reachedAt('api/users.go', 'name := user.Name')).toEqual([]);
});
it('leaves the package what its name names where nothing shadows it', () => {
expect(reachedAt('api/users.go', 'context.Background()')).toEqual([]);
expect(reachedAt('api/users.go', 'strings.TrimSpace(')).toEqual([]);
expect(reachedAt('textutil/read.go', 'url.Parse(raw)')).toEqual([]);
});
it('is read from the file as a sync leaves it', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-go-stdlib-locals-sync-'));
const write = (rel: string, content: string): void => {
fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true });
fs.writeFileSync(path.join(dir, rel), content);
};
write('go.mod', 'module example.com/app\n\ngo 1.22\n');
write('gin/context.go', files['gin/context.go']!);
// The call keeps its line and column: a stale reading would still see the parameter.
const handler = (param: string) => `package gin
func handler(${param} *Context) {
context.Next()
}
`;
write('gin/handler.go', handler('context'));
const synced = await CodeGraph.init(dir, { index: true });
try {
const nexts = (): string[] => {
const ids = synced.getNodesInFile('gin/handler.go').map((n) => n.id);
return synced.getOutgoingEdgesFrom(ids).filter((e) => e.line === 4).map((e) => synced.getNode(e.target)!.qualifiedName);
};
expect(nexts()).toEqual(['Context::Next']);
write('gin/handler.go', handler('c'));
await synced.sync();
expect(nexts()).toEqual([]);
} finally {
synced.close();
fs.rmSync(dir, { recursive: true, force: true });
}
}, 60_000);
});