From 8a5ba3d02c833f205b5453305b47fffee0008a6b Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Sat, 10 Oct 2026 23:50:55 +0000 Subject: [PATCH] fix(go): a call through a parameter or local named like a standard-library package reaches the project's method (#2489) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 1 + __tests__/go-stdlib-named-locals.test.ts | 494 +++++++++++++++++++++++ src/resolution/index.ts | 23 +- src/resolution/name-matcher.ts | 126 +++++- 4 files changed, 623 insertions(+), 21 deletions(-) create mode 100644 __tests__/go-stdlib-named-locals.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c14bfcb90..a984c54d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -104,6 +104,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - React Router routes kept in a table of their own are now indexed: the ASP.NET Core React template's `AppRoutes` array that `App.js` maps into ``, a `routes` array imported into `useRoutes(routes)` or `createBrowserRouter(routes)` or returned by a function as in `useRoutes(routes(isLoggedIn))`, route objects kept one per file and listed in `createBrowserRouter([MainRoutes, LoginRoutes])`, and routes written in place in `useRoutes([…])`. Before, route objects were read only from a file that itself creates a data router, so these apps had no routes, and their `navigate('/login')` calls and `` links led nowhere. Each route links to the page its `element` renders, past a guard like `` or ``, and through `lazy(() => import(…))` to the page it loads rather than a same-named page elsewhere in the repository. An `index: true` route is the page at its parent's address, and a parent route around others counts as their layout. A `{ path, element }` list that nothing hands to the router, such as a menu, still makes no routes. Re-index React projects after upgrading. - In Go, a call or type written through an import whose path ends in a version, or in something other than the package's name, is now known to go through that import: `yaml.Unmarshal(…)` after `import "go.yaml.in/yaml/v3"` or `"gopkg.in/yaml.v3"`, `sqlite3.Error` after `import "github.com/mattn/go-sqlite3"`, `klog.V(2)` after `import "k8s.io/klog/v2"`. Before, only the last part of the path named such an import (`v3`, `yaml.v3`, `go-sqlite3`), so the name was matched on its own and could link to any project function, method or type that shared it: kubernetes' `klog.V(…)` calls were linked to a logging wrapper's `V` method, and etcd's `semver.Version` parameters to an unrelated `Version` function. A name from another module now links to nothing, and one through a package of your own project, like `kit.New()` after `import "example.com/kit/v2"`, links to that package's symbol. A comment in an import block is also no longer taken for the name of the import after it. Re-index Go projects after upgrading. Thanks @danusha2345 for the comment fix. (#2374) - In Go, a call is now matched to the package or variable it is actually made on. Before, a call through a package, like `klog.Infof("Log using Infof")` or `digest.Digest(dig)` inside `&distribution.Descriptor{Digest: …}`, could link to a project method of the same name when the name appeared again on the line, and in `klog.Error(err.Error())` the `err.Error()` call was taken for a call into `klog`. A parameter, receiver or local variable named like an import, like etcd's `jwt, err := newTokenProviderJWT(…)` or the `suite` receiver in a testify test suite, now counts as that variable, so calls on it link to the methods of the type it is declared as, and to nothing when that type comes from outside your project. Re-index Go projects after upgrading. +- In Go, a call through a parameter or local variable named like a standard-library package, such as `context`, `log`, `trace`, `printer` or `token`, now links to the method it calls. Before, every call written `context.…` or `printer.…` was taken for a call into the standard library and linked to nothing: gin's `context.AbortWithError(…)` inside `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(…)`. The variable's declaration decides the method: the type it is declared as, or the package of the project function its value comes from. A variable holding a value from outside your project, like `scanner := bufio.NewScanner(r)` or `url, err := url.Parse(raw)`, still links to nothing, and one whose type can't be told links only to a method no other type in the project has, never to one picked because it matches the variable's name. Re-index Go projects after upgrading. - React Router routes written as JSX now read `` the way route objects do: inside `}>`, `} />` is the page at `/`, and `Layout` counts as the layout of every route nested in it, as does a guard with no path of its own like `}>`. Before, the index route was never read, so `/` led to the layout itself and a `` holding only an index route led to nothing. A route's `element` written on lines of its own, the way Prettier formats a long one, or wrapped in `}>`, now links to the page it shows rather than to nothing or to the fallback, and a path written in braces, like `path={"agents"}`, is read like any other. Re-index React projects after upgrading. - In JavaScript and TypeScript, a module loaded with `require('./x').default` or `const { default: X } = require('./x')` now links to its default export when it is written as an ES module: bitwarden's desktop app loads `export default class OsBiometricsServiceMac` this way before calling `new OsBiometricsServiceMac(…)`, and React Native's own libraries load many of their modules like this. Before, `.default` was looked up only as an export named `default`, which `export default` is not, so the `new` and the calls made through it linked to nothing, to the local variable holding the module, or to a method of the same name somewhere else. A Svelte component loaded this way and created with `new App({ target })` now links to the component. A CommonJS module that sets `exports.default` itself, or both `module.exports` and `module.exports.default` as fastify does, still links to what it sets, and one that sets no `default` still links to nothing. Re-index JavaScript and TypeScript projects after upgrading. - A React Router route that lazily loads a folder, like `lazy: () => import('@/pages/invocation_page')`, now links to the page the folder's `index.ts` passes on, whether it writes `export * from './invocation_page'`, `export { default } from './page'` or `export { Page as Component } from './page'`, even through another `index.ts`. A route module that names its page with `export { Page as Component }` links to it as well, and so does a page loaded with `lazy(() => import(…))` through such an `index.ts`. Before, these routes linked to nothing, so their pages had no route leading to them. Re-index React projects after upgrading. diff --git a/__tests__/go-stdlib-named-locals.test.ts b/__tests__/go-stdlib-named-locals.test.ts new file mode 100644 index 000000000..d15c68258 --- /dev/null +++ b/__tests__/go-stdlib-named-locals.test.ts @@ -0,0 +1,494 @@ +/** + * 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 = { + '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); +}); diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 7c16dbd13..24a5ad5ec 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -21,7 +21,7 @@ import { isImportableKind, CPP_DEFINE_SIGNATURE, } from './types'; -import { isPythonSelfCall, matchJsStoreBindingCall, isUnresolvedJsMemberCall, matchObjectPathCall, thisScopeCaller, isVisibleAcrossFiles, matchReference, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesCodeBoundary, gateLanguageMatch, dumpNameMatcherProfile, clearNameMatcherMemos, isRustNameInScope, CASE_INSENSITIVE_LANGUAGES, isDartMemberRead, matchDartMemberRead, isDartChainLink, matchDartChainLink, isDartAnnotation, matchDartAnnotation, isStdMethodName, isGoUnknownQualified, isGoBareName, goTypePositionTarget, GO_TYPE_KINDS, matchGoAssertedCall, isGoAssertedLocal } from './name-matcher'; +import { isPythonSelfCall, matchJsStoreBindingCall, isUnresolvedJsMemberCall, matchObjectPathCall, thisScopeCaller, isVisibleAcrossFiles, matchReference, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesCodeBoundary, gateLanguageMatch, dumpNameMatcherProfile, clearNameMatcherMemos, isRustNameInScope, CASE_INSENSITIVE_LANGUAGES, isDartMemberRead, matchDartMemberRead, isDartChainLink, matchDartChainLink, isDartAnnotation, matchDartAnnotation, isStdMethodName, isGoUnknownQualified, isGoProjectLocalCall, GO_STDLIB_PACKAGES, isGoBareName, goTypePositionTarget, GO_TYPE_KINDS, matchGoAssertedCall, isGoAssertedLocal } from './name-matcher'; import { isVisibleCppMacro, clearCppMacroVisibility } from './cpp-macro-visibility'; import { isCppConstructorRef, matchCppConstructor } from './cpp-constructor'; import { isCppSupertypeRef, matchCppSupertype, clearCppSupertypeMemos } from './cpp-supertypes'; @@ -133,20 +133,6 @@ const PYTHON_BUILT_IN_METHODS = new Set([ 'read', 'write', 'readline', 'readlines', 'close', 'flush', 'seek', ]); -const GO_STDLIB_PACKAGES = new Set([ - 'fmt', 'os', 'io', 'net', 'http', 'log', 'math', 'sort', 'sync', - 'time', 'path', 'bytes', 'strings', 'strconv', 'errors', 'context', - 'json', 'xml', 'csv', 'html', 'template', 'regexp', 'reflect', - 'runtime', 'testing', 'flag', 'bufio', 'crypto', 'encoding', - 'filepath', 'hash', 'mime', 'rand', 'signal', 'sql', 'syscall', - 'unicode', 'unsafe', 'atomic', 'binary', 'debug', 'exec', 'heap', - 'ring', 'scanner', 'tar', 'zip', 'gzip', 'zlib', 'tls', 'url', - 'user', 'pprof', 'trace', 'ast', 'build', 'parser', 'printer', - 'token', 'types', 'cgo', 'plugin', 'race', 'ioutil', - // Kubernetes-common stdlib aliases - 'utilruntime', 'utilwait', 'utilnet', -]); - const GO_BUILT_INS = new Set([ 'make', 'new', 'len', 'cap', 'append', 'copy', 'delete', 'close', 'panic', 'recover', 'print', 'println', 'complex', 'real', 'imag', @@ -2874,8 +2860,11 @@ export class ReferenceResolver { const pkg = name.substring(0, dotIdx); // Not a local bound from a type assertion that is named like one: // grpc-go's `parser.ParseConfig(…)` after `parser, ok := - // b.(balancer.ConfigParser)`. - if (GO_STDLIB_PACKAGES.has(pkg) && !isGoAssertedLocal(pkg, ref, this.context)) { + // b.(balancer.ConfigParser)`. Nor a call on any other parameter or + // local of the name that can hold one of the project's values: gin's + // `context.AbortWithError(…)` in `func(context *Context)`. + if (GO_STDLIB_PACKAGES.has(pkg) && !isGoAssertedLocal(pkg, ref, this.context) && + !isGoProjectLocalCall(ref, this.context)) { return true; } } diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 7714cc34e..a49057f60 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -1924,6 +1924,26 @@ function rustModuleDir(filePath: string): string { return path.posix.join(dir, base.replace(/\.rs$/, '')); } +/** + * Standard-library package names (and kubernetes' aliases of a few of its own + * util packages). A dotted Go reference through one is taken for a call into + * that package, unless the name is a parameter or local where the call is + * written (isGoProjectLocalCall). + */ +export const GO_STDLIB_PACKAGES: ReadonlySet = new Set([ + 'fmt', 'os', 'io', 'net', 'http', 'log', 'math', 'sort', 'sync', + 'time', 'path', 'bytes', 'strings', 'strconv', 'errors', 'context', + 'json', 'xml', 'csv', 'html', 'template', 'regexp', 'reflect', + 'runtime', 'testing', 'flag', 'bufio', 'crypto', 'encoding', + 'filepath', 'hash', 'mime', 'rand', 'signal', 'sql', 'syscall', + 'unicode', 'unsafe', 'atomic', 'binary', 'debug', 'exec', 'heap', + 'ring', 'scanner', 'tar', 'zip', 'gzip', 'zlib', 'tls', 'url', + 'user', 'pprof', 'trace', 'ast', 'build', 'parser', 'printer', + 'token', 'types', 'cgo', 'plugin', 'race', 'ioutil', + // Kubernetes-common stdlib aliases + 'utilruntime', 'utilwait', 'utilnet', +]); + interface GoQualification { /** The package name written before the reference's name, as spelled. */ written?: string; @@ -2125,10 +2145,19 @@ interface GoDecl { type?: string; /** The package a `:=` takes its value from: `clocktesting` in `clock := clocktesting.NewFakeClock(…)`. */ from?: string; + /** The function of the file's own package a `:=` takes its value from: `newStore` in `store := newStore(…)`. */ + callee?: string; } const GO_SCOPE_INDEXES = new WeakMap>(); const GO_TYPE_KEYWORDS: ReadonlySet = new Set(['chan', 'func', 'interface', 'map', 'struct']); +/** What a `:=` can call that is no function of the file's package: a builtin, a conversion to a predeclared type, a literal. */ +const GO_PREDECLARED_CALLS: ReadonlySet = new Set([ + ...GO_TYPE_KEYWORDS, 'append', 'cap', 'clear', 'close', 'complex', 'copy', 'delete', 'imag', 'len', 'make', 'max', 'min', + 'new', 'panic', 'print', 'println', 'real', 'recover', 'any', 'bool', 'byte', 'complex64', 'complex128', 'error', + 'float32', 'float64', 'int', 'int8', 'int16', 'int32', 'int64', 'rune', 'string', 'uint', 'uint8', 'uint16', + 'uint32', 'uint64', 'uintptr', +]); /** * The parameter or local of the Go function around `ref` that `name` is @@ -2209,12 +2238,17 @@ function readGoScopes(code: string): GoScopeIndex { const first = names[names.length - 1]![1]; const lead = code.slice(code.lastIndexOf('\n', first - 1) + 1, first); const kind = /^\s*(?:\}\s*else\s+)?(?:if|for|switch)\b/.test(lead) ? 'header' : /^\s*case\b/.test(lead) ? 'case' : 'local'; - // `x := &pkg.T{…}` writes the type it declares; `x := pkg.New(…)` the - // package its value comes from. + // `x := &pkg.T{…}`, `x := T{…}` and `x := new(T)` write the type they + // declare; `x := pkg.New(…)` the package its value comes from, `x := + // newStore(…)` the function of its own package. const rhs = code.slice(i + 2, i + 160); - const type = names.length === 1 ? /^[ \t]*&?[ \t]*([A-Za-z_]\w*\.[A-Za-z_]\w*)[ \t]*\{/.exec(rhs)?.[1] : undefined; + const literal = /^[ \t]*&?[ \t]*((?:[A-Za-z_]\w*\.)?[A-Za-z_]\w*)[ \t]*\{/.exec(rhs)?.[1] ?? + /^[ \t]*new\([ \t]*((?:[A-Za-z_]\w*\.)?[A-Za-z_]\w*)[ \t]*\)/.exec(rhs)?.[1]; + const type = names.length === 1 && literal && !GO_TYPE_KEYWORDS.has(literal) ? literal : undefined; const from = /^[ \t]*&?[ \t]*([A-Za-z_]\w*)\.[A-Za-z_]\w*[ \t]*[({]/.exec(rhs)?.[1]; - for (const [name, at] of names) add(name, { at, kind, type, from }); + const call = from ? undefined : /^[ \t]*([A-Za-z_]\w*)[ \t]*(?:\[(?:[^[\]\n]|\[[^[\]\n]*\])*\][ \t]*)?\(/.exec(rhs)?.[1]; + const callee = call && !GO_PREDECLARED_CALLS.has(call) ? call : undefined; + for (const [name, at] of names) add(name, { at, kind, type, from, callee }); } // `var x T`, `var a, b = …`, `const (…)`: names before a type, an `=` or // the line's end — in a group, on lines outside the brackets of a value @@ -11349,6 +11383,14 @@ export function matchMethodCall( // receiver type we can try to infer from its local declaration. const inferableReceiver = dotMatch || luaColonMatch || rDollarMatch; + // A Go variable named like a standard-library package resolves by its + // declaration alone (isGoProjectLocalCall let it past the package list). + if (ref.language === 'go' && dotMatch && GO_STDLIB_PACKAGES.has(objectOrClass!)) { + const local = goLocalDecl(objectOrClass!, ref, context); + const value = local && goLocalValue(objectOrClass!, local, ref, context); + if (value !== undefined) return value && matchGoStdlibNamedLocalCall(objectOrClass!, methodName!, value, ref, context); + } + // A Dart call through a type's name is to that type's own member, or to // nothing in the project. if (ref.language === 'dart' && dotMatch) { @@ -12227,6 +12269,82 @@ export function isGoAssertedLocal(name: string, ref: UnresolvedRef, context: Res return written !== undefined && goWrittenType(written, ref, context) !== undefined; } +/** + * What a Go parameter or local holds where `ref` calls through it, by its + * declaration in scope there: the project type it is written or asserted as + * (`func(context *Context)`, `user *models.User`, `parser := &files{…}`); + * null for a value with no method the project declares — a type from outside + * the project or a predeclared one, or what an outside package's function + * hands out (`scanner := bufio.NewScanner(f)`, `url, err := url.Parse(s)`); + * otherwise the directory of the project package its value comes from, when + * a call says so (`trace := traceutil.Get(ctx)`, `context := + * NewSecurityContext(…)`). + */ +type GoLocalValue = { type: GoProjectType } | { origin: string | undefined } | null; + +function goLocalValue(name: string, local: GoDecl, ref: UnresolvedRef, context: ResolutionContext): GoLocalValue { + const written = goAssertedLocalType(name, ref, context) ?? local.type; + const type = written === undefined ? undefined : goWrittenType(written, ref, context); + if (type !== undefined) return type && { type }; + if (local.callee) return { origin: goPackageDir(ref.filePath) }; + const dir = local.from === undefined ? undefined : goImportPackageDir(local.from, ref.filePath, context); + return dir === null ? null : { origin: dir }; +} + +/** + * Whether a dotted Go call through a name taken for a standard-library + * package (GO_STDLIB_PACKAGES) is made on a parameter or local of the + * function around it that may hold one of the project's values: gin's + * `context.AbortWithError(…)` in `func(context *Context)`, harbor's + * `context.IsAuthenticated()` after `context := NewSecurityContext(…)`. + */ +export function isGoProjectLocalCall(ref: UnresolvedRef, context: ResolutionContext): boolean { + const dot = ref.referenceName.indexOf('.'); + if (dot <= 0 || ref.referenceKind !== 'calls') return false; + const name = ref.referenceName.slice(0, dot); + const local = goLocalDecl(name, ref, context); + return local !== undefined && goLocalValue(name, local, ref, context) !== null; +} + +/** + * A call through a Go parameter or local named like a standard-library + * package — one isGoProjectLocalCall let past the package list — resolved by + * what its declaration says, never by its name: `plugin`, `printer`, `path` + * and `user` fit many of a big tree's types. kubernetes' `printer := + * NewTablePrinter(…)` is no kubeadm `Printer`, and a `plugin` a volume plugin + * manager hands out is none of its forty `…Plugin` types in particular. + * - A project type calls its own method, an interface's or one embedding + * promotes into it, or nothing: a `context PostStartHookContext`'s `Done` + * comes from the `context.Context` it embeds. + * - A value a function of the project hands out calls a method of that + * function's package: the one of a type named like the variable (`printer + * := initPrinterFromCmd(cmd)` is a `printer`), or the only one of its name + * there. + * - Any other value (a range or map value, a method's result), or one whose + * package declares no method of the name (etcd's `tls := newTLS(…)` is a + * `transport.TLSInfo`), calls only a method no other type of the project + * declares, and none named like one of the standard library's. + */ +function matchGoStdlibNamedLocalCall( + receiver: string, + method: string, + value: Exclude, + ref: UnresolvedRef, + context: ResolutionContext, +): ResolvedRef | null { + if ('type' in value) return resolveMethodOnType(value.type.name, method, ref, context, 0.9, 'instance-method', value.type.dir); + const methods = context.getNodesByName(method).filter((n) => n.kind === 'method' && n.language === 'go' && + n.id !== ref.fromNodeId && (isTestPath(ref.filePath) || !isTestPath(n.filePath))); + const own = value.origin === undefined ? [] : methods.filter((n) => goPackageDir(n.filePath) === value.origin); + if (own.length > 0) { + const named = own.filter((n) => n.qualifiedName === `${receiver}::${method}`); + const pool = named.length > 0 ? named : own; + return pool.length === 1 ? { original: ref, targetNodeId: pool[0]!.id, confidence: 0.8, resolvedBy: 'instance-method' } : null; + } + return methods.length === 1 && !isStdMethodName('go', method) + ? { original: ref, targetNodeId: methods[0]!.id, confidence: 0.7, resolvedBy: 'instance-method' } : null; +} + /** * The names a Go file may bind from a type assertion — the first name before * each `:=` or `=` on a line that asserts a type — so that only calls through