From 85c520794dd954edaa622de7534ce9bf947bf495 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Wed, 7 Oct 2026 07:06:54 -0500 Subject: [PATCH 1/2] fix(go): a call through a type assertion reaches the asserted type's method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both extractors record `srv.(KVServer).Range(ctx, in)` as a bare `Range` call at the column where its receiver expression starts, as they record any call through an expression. Resolution matched the name alone, so every handler protoc-gen-go-grpc emits went to the `UnimplementedKVServer` stub beside the `KVServer` interface (70 edges in etcd), and calls like `c.Reader.(*pipe).Close()`, `p.(Pausable).Pause()` or `v.(featuregate.MutableFeatureGate).Set(…)` linked to a namesake or to nothing. The call's chain is now read back from its column (operand, selectors, assertions, calls and indexes, past strings, runes and comments, and on to the next line after a trailing `.`). When the link of the call's name follows an assertion, the call is a method of the asserted type, found where Go finds it: - a bare `T` or `*T` in the call's own package, or one it dot-imports; - a `pkg.T` in the imported project package; - its own method, the one its interface declares, or one promoted from a type it embeds, through the package-scoped lookup #2361 added. A type from outside the project (`http.Flusher`), a predeclared one (`error`) or a type literal (`interface{ Flush() }`) links nothing. The check runs ahead of the framework, import and name strategies, and ahead of the built-in filter, which dropped a method named `close` or `copy` called through an assertion. Two calls of one name in a chain share the column (`b.(*Builder).Add(1).Add(2)`); both are taken for the one made through the assertion, whose edge is there either way. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/go-type-assertion-calls.test.ts | 380 ++++++++++++++++++++++ src/resolution/index.ts | 7 +- src/resolution/name-matcher.ts | 153 +++++++++ 4 files changed, 540 insertions(+), 1 deletion(-) create mode 100644 __tests__/go-type-assertion-calls.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index bfa4c1d08..2aed81e51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In Dart, reading a getter such as `box.area` or `status.label` now counts as calling it, so `codegraph callers` and impact list the code that reads it — whether the getter is declared on the class, inherited, or added by an extension — and a method an extension adds to an enum, like `shape.shout()`, now links as well. A plain field, a getter of a type from outside your project, or a value whose type isn't written down still links nothing. Re-index Dart projects after upgrading. Thanks @brookly255-lv for the report. (#2338) - In Dart, a type is now linked from more of the places it is written, not only from parameters and return types: the type an `extension … on` targets, a field's type, a top-level variable's type, generic arguments such as `Future.value(null)` or Riverpod's `final reportProvider = Family()`, and a local variable's type, a cast or a type check inside a function. So `callers` and impact for a model class now reach the extensions, models and providers that use it. Thanks @mg-mg-mg for the report. (#2327) - In Go, calls through a method receiver or parameter of an unexported type, the usual shape of gRPC and HTTP handlers (`s.service.AddItem()` inside `func (s *server) Create()`), now resolve, and always within that type's own package: another package's `server` with a same-named method no longer takes the call, and a method the type gets from a struct or interface it embeds is found too. Re-index Go projects after upgrading. Thanks @GoDiao for the report and the fix. (#2323) +- In Go, a call made through a type assertion, like `srv.(KVServer).Range(ctx, in)` in generated gRPC code, `c.Reader.(*pipe).Close()` or `v.(storage.Store).Fetch(key)`, now links to that method of the asserted type, including one the type gets from a struct or interface it embeds, and to nothing when the type comes from outside your project. Before, it linked to whichever method shared the name, so every gRPC handler pointed at the `Unimplemented…Server` stub instead of the server interface. Re-index Go projects after upgrading. - In Go, calls into a module whose `go.mod` is not at the project root now resolve, whether it is a `server/` backend next to a `web/` frontend or one of several modules side by side as in etcd, so calls like `store.New()` and `s.db.CreateItem()` find their targets instead of being treated as calls into a third-party package. A name written through a package, like a `job.OPCommand` result type or a field of type `artifact.Manager`, now links to that package's symbol rather than a same-named one elsewhere, which also corrects links in projects with a single `go.mod`. Re-index Go projects after upgrading. Thanks @GoDiao for the report and @danusha2345 for the fix. (#2322) - In Go, an exported type written without a package, like `Node` in `func Walk(v Visitor, node Node)`, now links to the type of that name in its own package, whether it is a struct, an interface or another kind of type. Before, it could link to a struct of the same name in an unrelated package, so prometheus's PromQL parser functions pointed at the Kubernetes discovery `Node` struct instead of the parser's own `Node` interface, or, for a result type like `Appender` in `func (f *fanout) Appender(…) Appender`, to the method itself. A type from a package outside your project, like `apiv1.Node` or `http.Handler`, no longer links to a struct of the same name in the file that uses it; a variadic parameter like `...storage.Filter` now links to that package's type; a route handler written as a method value, like `h.Follow` in an Echo or Gin app, links to that method rather than a model struct named `Follow`; and a method called on another call's result or on an indexed value, like `err[i].Error()` or a chained `.String()`, is no longer recorded as creating a struct of the same name. Re-index Go projects after upgrading. - In Go, a function or method passed as a value, like `sync.Pool{New: wm.new}`, now shows the function that passes it among its callers and impact even when it is named `new`, `nil`, `None`, `self` or a handful of similar words, or when a line break or comment sits beside the dot. Before, such a value was skipped, so a method used only that way looked unused. Re-index Go projects after upgrading. diff --git a/__tests__/go-type-assertion-calls.test.ts b/__tests__/go-type-assertion-calls.test.ts new file mode 100644 index 000000000..45e3cd1dc --- /dev/null +++ b/__tests__/go-type-assertion-calls.test.ts @@ -0,0 +1,380 @@ +/** + * A Go call through a type assertion is a call of the asserted type's method: + * + * func _KV_Range_Handler(srv interface{}, …) (interface{}, error) { + * return srv.(KVServer).Range(ctx, in) // etcd's generated gRPC code + * } + * + * Both extractors record such a call by its bare member name (`Range`), at + * the column where its receiver expression starts, as any call through an + * expression. Resolution matched the name alone, so every handler etcd + * generates went to `UnimplementedKVServer`'s stub instead of the `KVServer` + * interface method. The asserted type is now read back from the source: its + * own method, the method its interface declares, or one embedding promotes + * into it — written bare (its own package's, or a dot import's), as a + * pointer, or through a project package. A type from outside the project + * (`http.Flusher`) or a type literal links nothing. + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import CodeGraph from '../src/index'; +import { extractFromSource } from '../src/extraction'; +import { initGrammars, loadGrammarsForLanguages } from '../src/extraction/grammars'; +import { tryKernelExtract, resetKernelForTests } from '../src/extraction/kernel'; +import type { ExtractionResult } from '../src/types'; + +const KERNEL_PATH = path.join( + __dirname, + '..', + 'codegraph-kernel', + 'prebuilds', + `${process.platform}-${process.arch}`, + 'codegraph-kernel.node' +); +const kernelAvailable = fs.existsSync(KERNEL_PATH) || process.env.CODEGRAPH_KERNEL_EXPECT === '1'; + +/** etcd's generated server handlers, a pointer assertion, and a chain continued on the next line. */ +const HANDLERS = `package pb + +import "context" + +type KVServer interface { + Range(context.Context, *RangeRequest) (*RangeResponse, error) + Put(context.Context, *RangeRequest) (*RangeResponse, error) +} + +type UnimplementedKVServer struct{} + +func (UnimplementedKVServer) Range(context.Context, *RangeRequest) (*RangeResponse, error) { + return nil, nil +} + +func (UnimplementedKVServer) Put(context.Context, *RangeRequest) (*RangeResponse, error) { + return nil, nil +} + +type RangeRequest struct{} + +type RangeResponse struct{} + +func _KV_Range_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor func(context.Context, interface{}, func(context.Context, interface{}) (interface{}, error)) (interface{}, error)) (interface{}, error) { + in := new(RangeRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(KVServer).Range(ctx, in) + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(KVServer).Range(ctx, req.(*RangeRequest)) + } + return interceptor(ctx, in, handler) +} + +func _KV_Put_Handler(srv interface{}, ctx context.Context, in *RangeRequest) (interface{}, error) { + return srv.(KVServer). + Put(ctx, in) +} +`; + +const ENV_KEYS = ['CODEGRAPH_KERNEL', 'CODEGRAPH_KERNEL_LANGS'] as const; + +describe('Go calls through a type assertion are recorded at their receiver', () => { + let savedEnv: Record = {}; + + beforeAll(async () => { + await initGrammars(); + await loadGrammarsForLanguages(['go']); + }); + + beforeEach(() => { + savedEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]])); + resetKernelForTests(); + }); + + afterEach(() => { + for (const k of ENV_KEYS) { + if (savedEnv[k] === undefined) delete process.env[k]; + else process.env[k] = savedEnv[k]; + } + resetKernelForTests(); + }); + + function extract(backend: 'kernel' | 'wasm', source: string): ExtractionResult { + if (backend === 'wasm') { + process.env.CODEGRAPH_KERNEL = '0'; + return extractFromSource('pb/rpc_grpc.pb.go', source, 'go'); + } + delete process.env.CODEGRAPH_KERNEL; + process.env.CODEGRAPH_KERNEL_LANGS = 'all'; + const result = tryKernelExtract('pb/rpc_grpc.pb.go', source, 'go'); + expect(result, 'kernel extraction').not.toBeNull(); + return result!; + } + + const backends = kernelAvailable ? (['kernel', 'wasm'] as const) : (['wasm'] as const); + + for (const crlf of [false, true]) { + const source = crlf ? HANDLERS.replace(/\n/g, '\r\n') : HANDLERS; + it.each(backends)(`by the bare member name, at the receiver's column: %s${crlf ? ' (CRLF)' : ''}`, (backend) => { + const lines = source.split(/\r?\n/); + const refs = extract(backend, source).unresolvedReferences + .filter((r) => r.referenceKind === 'calls' && (r.referenceName.endsWith('Range') || r.referenceName.endsWith('Put'))) + .map((r) => `${r.referenceName} ${r.line}:${r.column}`) + .sort(); + // `line:column` of the receiver `srv` on the line `pick` finds. + const srvOn = (pick: (line: string) => boolean): string => { + const line = lines.findIndex(pick); + return `${line + 1}:${lines[line]!.indexOf('srv')}`; + }; + expect(refs).toEqual([ + `Put ${srvOn((l) => l.endsWith('srv.(KVServer).'))}`, + `Range ${srvOn((l) => l.includes('Range(ctx, in)'))}`, + `Range ${srvOn((l) => l.includes('Range(ctx, req.('))}`, + ].sort()); + }); + } +}); + +/** + * The module the resolution tests index. Namesakes sit where name matching + * looked first: a stub beside each interface, a same-file type with each + * method name, and an `alpha` package that sorts ahead of `storage`. + */ +const FILES: Record = { + 'go.mod': 'module example.com/app\n\ngo 1.22\n', + 'pb/rpc_grpc.pb.go': HANDLERS.replace(/\n/g, '\r\n'), + 'internal/bufconn/bufconn.go': `package bufconn + +import "io" + +type pipe struct{} + +func (p *pipe) Close() error { return nil } + +func (p *pipe) closeWrite() error { return nil } + +func (p *pipe) close() {} + +type conn struct { + io.Reader + io.Writer +} + +func (c *conn) Close() error { + err1 := c.Reader.(*pipe).Close() + err2 := c.Writer.(*pipe).closeWrite() + c.Reader.(*pipe).close() + if err1 != nil { + return err1 + } + return err2 +} +`, + 'alpha/alpha.go': `package alpha + +type Store interface { + Fetch(key string) string +} + +type sink struct{} + +func (s *sink) Fetch(key string) string { return "" } + +func (s *sink) Flush() error { return nil } + +func (s *sink) Read(p []byte) (int, error) { return 0, nil } +`, + 'storage/storage.go': `package storage + +type Store interface { + Fetch(key string) string +} + +type Base struct{} + +func (b *Base) Flush() error { return nil } +`, + 'store/store.go': `package store + +import "example.com/app/storage" + +type Reader interface { + Read(p []byte) (int, error) +} + +type ReadCloser interface { + Reader + Close() error +} + +type Wrapper struct { + *storage.Base + name string +} + +type Job struct{} + +func (j *Job) Start() {} + +type Factory interface { + New() *Job +} + +type Builder struct{} + +func (b *Builder) Add(n int) *Builder { return b } +`, + 'store/use.go': `package store + +import ( + "net/http" + + "example.com/app/storage" +) + +type sink struct{} + +func (s *sink) Flush() error { return nil } + +func (s *sink) Read(p []byte) (int, error) { return 0, nil } + +func (s *sink) Fetch(key string) string { return "" } + +func Use(v any, w http.ResponseWriter, p []byte) string { + v.(*Wrapper).Flush() + v.(ReadCloser).Read(p) + w.(http.Flusher).Flush() + return v.(storage.Store).Fetch("k") +} + +func Anon(v any) string { + return v.(interface{ Fetch(key string) string }).Fetch("k") +} + +func Run(v any) { + v.(Factory).New().Start() +} + +func lookup(key string) any { return nil } + +func Quoted() string { + return lookup(")").(storage.Store).Fetch("k") +} + +func (s *sink) Add(n int) *sink { return s } + +func Build(v any) { + v.(*Builder).Add(1).Add(2) +} +`, + 'dot/dot.go': `package dot + +import . "example.com/app/storage" + +func Get(v any) string { + return v.(Store).Fetch("k") +} +`, +}; + +describe.each(['default', 'wasm'])('Go calls through a type assertion resolve on the asserted type (%s)', (backend) => { + let root = ''; + let cg: CodeGraph | undefined; + let kernel: string | undefined; + + beforeAll(async () => { + kernel = process.env.CODEGRAPH_KERNEL; + if (backend === 'wasm') process.env.CODEGRAPH_KERNEL = '0'; + else delete process.env.CODEGRAPH_KERNEL; + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-go-assert-')); + 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 }); + if (kernel === undefined) delete process.env.CODEGRAPH_KERNEL; + else process.env.CODEGRAPH_KERNEL = kernel; + }); + + /** ` ::` for every call the function or method `qualifiedName` in `file` makes. */ + function calls(file: string, qualifiedName: string): string[] { + const fn = cg!.getNodesInFile(file).find((n) => n.qualifiedName === qualifiedName && (n.kind === 'function' || n.kind === 'method')); + expect(fn, `${qualifiedName} in ${file}`).toBeDefined(); + return cg! + .getOutgoingEdges(fn!.id) + .filter((e) => e.kind === 'calls') + .map((e) => `${e.line} ${cg!.getNode(e.target)!.filePath}::${cg!.getNode(e.target)!.qualifiedName}`) + .sort(); + } + + it("links etcd's gRPC handlers to the server interface's method, not the stub", () => { + expect(calls('pb/rpc_grpc.pb.go', '_KV_Range_Handler')).toEqual([ + '30 pb/rpc_grpc.pb.go::KVServer::Range', + '33 pb/rpc_grpc.pb.go::KVServer::Range', + ]); + // A chain continued on the next line. + expect(calls('pb/rpc_grpc.pb.go', '_KV_Put_Handler')).toEqual(['39 pb/rpc_grpc.pb.go::KVServer::Put']); + }); + + it("links a pointer assertion to the type's own method, one named like a builtin too", () => { + expect(calls('internal/bufconn/bufconn.go', 'conn::Close')).toEqual([ + '19 internal/bufconn/bufconn.go::pipe::Close', + '20 internal/bufconn/bufconn.go::pipe::closeWrite', + '21 internal/bufconn/bufconn.go::pipe::close', + ]); + }); + + it('follows embedding, a package qualifier and a dot import to the method', () => { + // *Wrapper's Flush is promoted from *storage.Base; ReadCloser's Read from Reader. + expect(calls('store/use.go', 'Use')).toEqual([ + '18 storage/storage.go::Base::Flush', + '19 store/store.go::Reader::Read', + '21 storage/storage.go::Store::Fetch', + ]); + expect(calls('dot/dot.go', 'Get')).toEqual(['6 storage/storage.go::Store::Fetch']); + // A string argument holding a parenthesis is not the end of the call. + expect(calls('store/use.go', 'Quoted')).toEqual([ + '35 storage/storage.go::Store::Fetch', + '35 store/use.go::lookup', + ]); + }); + + it('links nothing for a type from outside the project or a type literal', () => { + // http.Flusher's Flush, on line 20 of Use, has no edge (see above). + expect(calls('store/use.go', 'Anon')).toEqual([]); + const flush = cg!.getNodesInFile('store/use.go').find((n) => n.qualifiedName === 'sink::Flush')!; + expect(cg!.getIncomingEdges(flush.id).filter((e) => e.kind === 'calls')).toEqual([]); + }); + + it("leaves a later link of the chain to the type the call before it returns", () => { + expect(calls('store/use.go', 'Run')).toEqual([ + '29 store/store.go::Factory::New', + '29 store/store.go::Job::Start', + ]); + }); + + it('takes two calls of one name in a chain for the one made through the assertion', () => { + // Both `Add` calls start at `v`, so nothing tells them apart. + expect([...new Set(calls('store/use.go', 'Build'))]).toEqual(['41 store/store.go::Builder::Add']); + }); + + it('never links a namesake in another package or a same-file type', () => { + const decoys = [ + ...cg!.getNodesInFile('alpha/alpha.go'), + ...cg!.getNodesInFile('store/use.go').filter((n) => n.qualifiedName.startsWith('sink::')), + ...cg!.getNodesInFile('pb/rpc_grpc.pb.go').filter((n) => n.qualifiedName.startsWith('UnimplementedKVServer::')), + ].filter((n) => n.kind === 'method'); + expect(decoys.length).toBe(10); + for (const decoy of decoys) { + expect(cg!.getIncomingEdges(decoy.id).filter((e) => e.kind === 'calls' && e.provenance !== 'heuristic'), decoy.qualifiedName).toEqual([]); + } + }); +}); diff --git a/src/resolution/index.ts b/src/resolution/index.ts index f71e8c086..da3fff3b7 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, GO_TYPE_KINDS } 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, isGoBareName, GO_TYPE_KINDS, matchGoAssertedCall } from './name-matcher'; import { isVisibleCppMacro, clearCppMacroVisibility } from './cpp-macro-visibility'; import { isCppConstructorRef, matchCppConstructor } from './cpp-constructor'; import { isCppSupertypeRef, matchCppSupertype, clearCppSupertypeMemos } from './cpp-supertypes'; @@ -1216,6 +1216,11 @@ export class ReferenceResolver { // A Dart annotation (`@riverpod`, `@Riverpod(…)`) is a constant or a // constructor call, as written — never a method or function by its name. if (isDartAnnotation(ref)) return matchDartAnnotation(ref, this.context); + // A Go call through a type assertion (`srv.(KVServer).Range(ctx, in)`), + // which arrives by its bare name, is to a method of the asserted type, or + // to nothing the project declares. + const asserted = matchGoAssertedCall(ref, this.context); + if (asserted !== undefined) return asserted; // A section or snippet a Shopify theme's file names is that theme's own, // or nothing: Shopify never looks in another theme (see ./shopify-themes). diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 69bebef53..30b5d7721 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -8979,6 +8979,7 @@ export function clearNameMatcherMemos(context: ResolutionContext): void { SCALA_OBJECT_PACKAGES.delete(context); GO_QUALIFIERS.delete(context); GO_EMBEDS.delete(context); + GO_DOT_IMPORTS.delete(context); JAVA_FILE_SCOPES.delete(context); JAVA_ANCESTORS.delete(context); SCALA_SUPERS.delete(context); @@ -10739,6 +10740,158 @@ function goEmbeddedTypes(typeNode: Node, context: ResolutionContext): Array<{ na return embedded; } +/** + * A Go call made through a type assertion — `srv.(KVServer).Range(ctx, in)`, + * `c.Reader.(*pipe).Close()`, `v.(storage.Store).Fetch(k)` — calls a method of + * the asserted type: its own, the one its interface declares, or one embedding + * promotes into it. The call reaches the resolver by its bare name, at the + * column where its receiver expression starts, and name matching took any + * method of that name: every gRPC handler etcd generates went to the + * `UnimplementedKVServer` stub beside the `KVServer` interface. The type is + * the one Go finds: in the call's own package or a package it dot-imports for + * a bare name, in the imported package for a qualified one. A type found in + * none — one from outside the project (`http.Flusher`), a predeclared one + * (`error`) — or a type literal (`interface{ Flush() }`) links nothing. + * Undefined when the call is not made through an assertion. + */ +export function matchGoAssertedCall(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null | undefined { + if (ref.language !== 'go' || ref.referenceKind !== 'calls' || !/^[A-Za-z_]\w*$/.test(ref.referenceName)) return undefined; + const asserted = goAssertedType(ref, context); + if (asserted === undefined) return undefined; + // `KVServer`, `*pipe`, `storage.Store`, `*List[int]`: anything else is a type literal. + const type = /^\*?\s*(?:([A-Za-z_]\w*)\s*\.\s*)?([A-Za-z_]\w*)\s*(?:\[[\s\S]*\])?$/.exec(asserted); + if (!type) return null; + const [, qualifier, typeName] = type; + const declares = (dir: string | null | undefined): dir is string => !!dir && goPackageTypes(typeName!, dir, context).length > 0; + const dir = qualifier !== undefined + ? goImportPackageDir(qualifier, ref.filePath, context) + : [goPackageDir(ref.filePath), ...goDotImportDirs(ref.filePath, context)].find(declares); + if (!declares(dir)) return null; + return resolveMethodOnType(typeName!, ref.referenceName, ref, context, 0.9, 'instance-method', dir); +} + +/** How many lines past a Go call's first its chain is read on. */ +const GO_CHAIN_LINES = 30; + +/** + * The type a Go call's receiver is asserted to, as written (`KVServer`, + * `*pipe`, `storage.Store`). Every call of a chain records the chain's start, + * so the call is a `.name(…)` link of the chain read from there, and the link + * before it is the assertion. Two links of one name in a chain + * (`b.(*Builder).Add(1).Add(2)`) are told apart by nothing, and both are taken + * for the one made through the assertion: that call is there either way. + * Undefined when no link of the name follows an assertion, or no chain + * starts at the column. + */ +function goAssertedType(ref: UnresolvedRef, context: ResolutionContext): string | undefined { + const lines = context.getFileLines?.(ref.filePath) ?? context.readFile(ref.filePath)?.split(/\r?\n/); + const line = lines?.[ref.line - 1]; + if (!lines || line === undefined || ref.column >= line.length) return undefined; + // Most calls through an expression assert nothing: no `.(` after the + // column, nor a chain that goes on past the line. + if (line.indexOf('.(', ref.column) < 0 && !/\.\s*$/.test(line)) return undefined; + const links = goChainLinks(lines.slice(ref.line - 1, ref.line + GO_CHAIN_LINES).join('\n'), ref.column); + for (let k = 1; k + 1 < links.length; k++) { + if (links[k - 1]!.kind === 'assert' && links[k]!.kind === 'select' && links[k]!.text === ref.referenceName && + links[k + 1]!.kind === 'call') return links[k - 1]!.text; + } + return undefined; +} + +interface GoChainLink { + kind: 'operand' | 'select' | 'assert' | 'call' | 'index'; + /** An operand's or selector's name, an assertion's type. */ + text: string; +} + +/** + * The links of the Go expression that starts at `start`: its operand (a name + * or a parenthesized expression), then each selector, type assertion, call + * and index. A line break ends it, except right after a `.`, where Go + * inserts no semicolon. + */ +function goChainLinks(text: string, start: number): GoChainLink[] { + const links: GoChainLink[] = []; + const NAME = /[A-Za-z_]\w*/y; + let i = start; + const name = (): string | null => { + NAME.lastIndex = i; + const m = NAME.exec(text); + if (m) i += m[0].length; + return m ? m[0] : null; + }; + const group = (): string | null => { + const close = goClosingBracket(text, i); + if (close < 0) return null; + const inner = text.slice(i + 1, close); + i = close + 1; + return inner; + }; + const operand = text[i] === '(' ? group() : name(); + if (operand === null) return links; + links.push({ kind: 'operand', text: operand }); + for (;;) { + while (text[i] === ' ' || text[i] === '\t') i++; + if (text[i] === '.') { + i++; + while (i < text.length && WHITESPACE.test(text[i]!)) i++; + const type = text[i] === '(' ? group() : null; + const member = type === null ? name() : null; + if (type !== null) links.push({ kind: 'assert', text: type.trim() }); + else if (member !== null) links.push({ kind: 'select', text: member }); + else break; + } else if (text[i] === '(' || text[i] === '[') { + // A call whose arguments run past what was read is still a call. + links.push({ kind: text[i] === '(' ? 'call' : 'index', text: '' }); + if (group() === null) break; + } else break; + } + return links; +} + +/** The bracket closing the one at `open`, past Go strings, runes and comments; -1 when it does not close. */ +function goClosingBracket(text: string, open: number): number { + let depth = 0; + for (let i = open; i < text.length; i++) { + const c = text[i]!; + if (c === '"' || c === '\'') { + for (i++; i < text.length && text[i] !== c && text[i] !== '\n'; i++) if (text[i] === '\\') i++; + } else if (c === '`') { + i = text.indexOf('`', i + 1); + if (i < 0) return -1; + } else if (c === '/' && text[i + 1] === '/') { + i = text.indexOf('\n', i); + if (i < 0) return -1; + } else if (c === '/' && text[i + 1] === '*') { + i = text.indexOf('*/', i + 2); + if (i < 0) return -1; + i++; + } else if (c === '(' || c === '[' || c === '{') depth++; + else if ((c === ')' || c === ']' || c === '}') && --depth === 0) return i; + } + return -1; +} + +const GO_DOT_IMPORTS = new WeakMap>(); + +/** The project packages a Go file dot-imports (`import . "example.com/app/storage"`), by directory: their names it reads bare. */ +function goDotImportDirs(filePath: string, context: ResolutionContext): string[] { + let memo = GO_DOT_IMPORTS.get(context); + if (!memo) GO_DOT_IMPORTS.set(context, (memo = new Map())); + let dirs = memo.get(filePath); + if (dirs) return dirs; + dirs = []; + for (const line of context.getFileLines?.(filePath) ?? context.readFile(filePath)?.split(/\r?\n/) ?? []) { + // The imports come before any other declaration. + if (/^(?:func|type|var|const)\b/.test(line)) break; + const dot = /^\s*(?:import\s+)?\.\s+"([^"]+)"/.exec(line); + const dir = dot ? context.getGoPackageDir?.(dot[1]!, filePath) : null; + if (dir) dirs.push(dir); + } + memo.set(filePath, dirs); + return dirs; +} + /** Go builtin/primitive field types that can never carry a project method. */ const GO_BUILTIN_FIELD_TYPES = new Set([ 'string', 'bool', 'byte', 'rune', 'error', 'any', From 09a121654636817223d612fc55a330b77e9bd0e7 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Wed, 7 Oct 2026 08:01:34 -0500 Subject: [PATCH 2/2] fix(go): an assertion to an alias of an outside type links nothing Since #2417 a Go `type A = B` is a node, and an alias of a type from outside the project (`type Ctx = context.Context`) makes the package-scoped method lookup step aside so the method is found by name, as before aliases had nodes. For a call through an assertion to such an alias, `v.(Ctx).Done()`, that fallback took any project method named `Done`. The asserted type's methods are the outside type's, so the call now links nothing. An alias of a project type (`type Keeper = storage.Store`) is followed to the type it names, which the tests now pin as well. Co-Authored-By: Claude Opus 5.5 --- __tests__/go-type-assertion-calls.test.ts | 28 +++++++++++++++++++++-- src/resolution/name-matcher.ts | 10 +++++--- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/__tests__/go-type-assertion-calls.test.ts b/__tests__/go-type-assertion-calls.test.ts index 45e3cd1dc..efd1da414 100644 --- a/__tests__/go-type-assertion-calls.test.ts +++ b/__tests__/go-type-assertion-calls.test.ts @@ -186,6 +186,10 @@ func (s *sink) Fetch(key string) string { return "" } func (s *sink) Flush() error { return nil } func (s *sink) Read(p []byte) (int, error) { return 0, nil } + +type Ctx struct{} + +func (c *Ctx) Done() <-chan struct{} { return nil } `, 'storage/storage.go': `package storage @@ -199,7 +203,11 @@ func (b *Base) Flush() error { return nil } `, 'store/store.go': `package store -import "example.com/app/storage" +import ( + "context" + + "example.com/app/storage" +) type Reader interface { Read(p []byte) (int, error) @@ -226,6 +234,10 @@ type Factory interface { type Builder struct{} func (b *Builder) Add(n int) *Builder { return b } + +type Ctx = context.Context + +type Keeper = storage.Store `, 'store/use.go': `package store @@ -269,6 +281,14 @@ func (s *sink) Add(n int) *sink { return s } func Build(v any) { v.(*Builder).Add(1).Add(2) } + +func Wait(v any) { + v.(Ctx).Done() +} + +func Keep(v any) string { + return v.(Keeper).Fetch("k") +} `, 'dot/dot.go': `package dot @@ -340,6 +360,8 @@ describe.each(['default', 'wasm'])('Go calls through a type assertion resolve on '21 storage/storage.go::Store::Fetch', ]); expect(calls('dot/dot.go', 'Get')).toEqual(['6 storage/storage.go::Store::Fetch']); + // An alias is the type it names. + expect(calls('store/use.go', 'Keep')).toEqual(['49 storage/storage.go::Store::Fetch']); // A string argument holding a parenthesis is not the end of the call. expect(calls('store/use.go', 'Quoted')).toEqual([ '35 storage/storage.go::Store::Fetch', @@ -350,6 +372,8 @@ describe.each(['default', 'wasm'])('Go calls through a type assertion resolve on it('links nothing for a type from outside the project or a type literal', () => { // http.Flusher's Flush, on line 20 of Use, has no edge (see above). expect(calls('store/use.go', 'Anon')).toEqual([]); + // Ctx is an alias of context.Context. + expect(calls('store/use.go', 'Wait')).toEqual([]); const flush = cg!.getNodesInFile('store/use.go').find((n) => n.qualifiedName === 'sink::Flush')!; expect(cg!.getIncomingEdges(flush.id).filter((e) => e.kind === 'calls')).toEqual([]); }); @@ -372,7 +396,7 @@ describe.each(['default', 'wasm'])('Go calls through a type assertion resolve on ...cg!.getNodesInFile('store/use.go').filter((n) => n.qualifiedName.startsWith('sink::')), ...cg!.getNodesInFile('pb/rpc_grpc.pb.go').filter((n) => n.qualifiedName.startsWith('UnimplementedKVServer::')), ].filter((n) => n.kind === 'method'); - expect(decoys.length).toBe(10); + expect(decoys.length).toBe(11); for (const decoy of decoys) { expect(cg!.getIncomingEdges(decoy.id).filter((e) => e.kind === 'calls' && e.provenance !== 'heuristic'), decoy.qualifiedName).toEqual([]); } diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 5b50a7d4d..56a9417e7 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -11020,9 +11020,10 @@ function goEmbeddedTypes(typeNode: Node, context: ResolutionContext): Array<{ na * method of that name: every gRPC handler etcd generates went to the * `UnimplementedKVServer` stub beside the `KVServer` interface. The type is * the one Go finds: in the call's own package or a package it dot-imports for - * a bare name, in the imported package for a qualified one. A type found in - * none — one from outside the project (`http.Flusher`), a predeclared one - * (`error`) — or a type literal (`interface{ Flush() }`) links nothing. + * a bare name, in the imported package for a qualified one, and an alias is + * the type it names. A type found in none — one from outside the project + * (`http.Flusher`), a predeclared one (`error`) — an alias of such a type, or + * a type literal (`interface{ Flush() }`) links nothing. * Undefined when the call is not made through an assertion. */ export function matchGoAssertedCall(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null | undefined { @@ -11038,6 +11039,9 @@ export function matchGoAssertedCall(ref: UnresolvedRef, context: ResolutionConte ? goImportPackageDir(qualifier, ref.filePath, context) : [goPackageDir(ref.filePath), ...goDotImportDirs(ref.filePath, context)].find(declares); if (!declares(dir)) return null; + // An alias of a type from outside the project (`type Ctx = context.Context`) + // has that type's methods, none the project declares. + if (goPackageTypes(typeName!, dir, context).every((t) => goAliasTarget(t, context) === null)) return null; return resolveMethodOnType(typeName!, ref.referenceName, ref, context, 0.9, 'instance-method', dir); }