From f7aa43ab6ed40caeb0b108c25bbc549efce732e9 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Wed, 7 Oct 2026 06:58:05 -0500 Subject: [PATCH] fix(go): implementing an interface needs matching parameter and result counts goImplementsEdges matched method names only, so a struct whose same-named method takes or returns a different number of values, which Go can never accept, was linked as an implementation (etcd's watch cache as a peerGetter), and enough such look-alikes filled the per-interface cap before the real implementer (grpc-go's xDS TransportBuilder). Each wanted method now also needs a declaration with its parameter and result counts, read off the stored signature text; a signature that doesn't read rules nothing out. A gRPC client interface (every method takes `opts ...grpc.CallOption` last) also accepts its RPCs' server arities, so `KVClient.Range -> kvServer.Range`, the bridge explore follows from a client call to its handler, stays. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/go-implements-arity.test.ts | 432 +++++++++++++++++++++++++ src/resolution/callback-synthesizer.ts | 225 +++++++++++-- 3 files changed, 637 insertions(+), 21 deletions(-) create mode 100644 __tests__/go-implements-arity.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fe1144fe4..401912c60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Framework support now switches on when an app's `package.json` sits three or more folders deep, as with an ASP.NET solution's Angular or React app in `src/Web/ClientApp/`, or a React app in a workspace folder like `web/ui/mantine-ui/`. Before, only the project root and the first two folder levels were read, so frameworks such as Angular, React Router, Vue Router, SvelteKit and TanStack Router could go undetected there: an Angular app's routes, its `routerLink` and `router.navigate(…)` navigation and its template's event bindings were missing, so a handler used only from a `(click)` binding had no callers, and a React app's `navigate('/login')` calls didn't reach their route. Re-index projects laid out this way after upgrading. - In Go, an interface that embeds another, like gin's `IRouter` embedding `IRoutes`, now links to it, so `codegraph_explore` and impact show the embedded interface as its base. Before, every embedded interface was dropped. An embedded field written through another package of your project, like `*refresh.Discovery` or `storage.Appender`, or with type arguments, like `List[int]`, now links too. One from the standard library or another module, like `io.Closer` or `sync.Mutex`, links to nothing rather than to a project type that shares its name, and a type-set constraint like `~int | ~float64` is not an embedding. An embedded type written without a package now always links to its own package's type: before, etcd's `Client`, which embeds its own `Lease` interface, was linked to a `Lease` struct in another package. Re-index Go projects after upgrading. - In Go, working out which structs implement an interface now counts the methods of the interfaces it embeds, and the methods a struct gets from the types it embeds. Before, only each type's own methods counted: etcd's `AuthReadTx`, which embeds `UnsafeAuthReader` and adds `RLock` and `RUnlock`, was listed as implemented by every read-write lock in the project, an interface that only embeds others had no implementations at all, and a struct that gets its methods from an embedded base, like prometheus's service discoveries embedding `refresh.Discovery` or gin's `Engine` embedding `RouterGroup`, was missing. `codegraph_explore`, impact and the type hierarchy now list the right implementations, and a call through the interface reaches a method the embedding struct overrides, like gin's `Engine.Use`. Re-index Go projects after upgrading. +- In Go, a struct now counts as implementing an interface only when its methods take as many parameters and return as many results as the interface's. Before, matching method names was enough, so etcd's watch cache, whose `Get` takes a context, a key and options, was listed as a `peerGetter`, whose `Get` takes a peer ID, and a call through an interface could be shown reaching methods it can never reach. Look-alikes could also crowd out the real implementations, so `codegraph_explore` stopped at grpc-go's xDS `TransportBuilder` instead of reaching the transport it builds. A server still counts for the gRPC client interface of the service it serves, like etcd's `kvServer` for `KVClient`, so a client's call keeps leading to the handler that serves it. Re-index Go projects after upgrading. - In Angular templates, a property binding, an interpolation, a structural directive or a control-flow block that calls one of the component's own members, like `[name]="icon()"`, `{{ label() }}`, `*ngIf="isOpen()"` or `@if (loading()) {`, now links the component to that member, and so does reading a getter, like `[disabled]="!canSave"`, or handing a method to a child component, like `[displayWith]="displayFn"` or `trackBy: trackById`. Before, only event bindings such as `(click)="save()"` were read, so a signal, a `computed` value, a getter or a method used only from a template had no callers and looked unused. A pipe, a template variable, a call on another object such as `form.reset()`, and a plain field read like `[value]="title"` still link nothing. Re-index Angular projects after upgrading. - 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. diff --git a/__tests__/go-implements-arity.test.ts b/__tests__/go-implements-arity.test.ts new file mode 100644 index 000000000..90903df2b --- /dev/null +++ b/__tests__/go-implements-arity.test.ts @@ -0,0 +1,432 @@ +/** + * Go's implicit interface satisfaction (#584) wants every method's signature + * identical, but goImplementsEdges matched method NAMES only: + * + * type peerGetter interface { Get(id types.ID) Peer } // etcd rafthttp + * func (c *Cache) Get(ctx context.Context, key string, + * opts ...clientv3.OpOption) (*clientv3.GetResponse, error) + * + * so etcd's watch cache "implemented" peerGetter, and every interface with a + * common method name (Get, Close, Build, Watch) collected structs that could + * never satisfy it — enough of them, at grpc-go's TransportBuilder, to fill + * the per-interface cap before the real implementer was reached. The pass now + * also wants each method's parameter and result counts, read off the stored + * signature text. + * + * One shape is kept on purpose: a server struct and the generated gRPC CLIENT + * interface of its service (`kvServer` and `KVClient`). That pair never + * satisfies Go, but its interface-impl edges are the bridge a client's call + * crosses to reach the handler serving it, so a client interface also takes + * its RPCs' server arities. + */ +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/index'; +import { goSignatureArity } from '../src/resolution/callback-synthesizer'; +import type { Edge, Node } from '../src/types'; + +const BALANCERS = 45; + +const FILES: Record = { + 'go.mod': 'module example.com/app\n\ngo 1.22\n', + // etcd's rafthttp: one Get takes an ID, the cache's takes a context, a key + // and options. + 'rafthttp/http.go': `package rafthttp + +import "context" + +type Peer interface{ attach() } + +type peerGetter interface { + Get(id uint64) Peer +} + +type Transport struct{} + +func (t *Transport) Get(id uint64) Peer { return nil } + +type Cache struct{} + +func (c *Cache) Get(ctx context.Context, key string, opts ...string) (string, error) { + return "", nil +} +`, + // A result count differs: prometheus's test Closer returns nothing. + 'testutil/closer.go': `package testutil + +type Closer interface { + Close() +} + +type tmpDir struct{} + +func (d *tmpDir) Close() {} + +type Block struct{} + +func (b *Block) Close() error { return nil } + +// Close is promoted from Block, still returning an error. +type wrapsBlock struct{ Block } +`, + // Comments, a trailing comma and grouped names all count right. + 'storage/storage.go': `package storage + +type Appender interface { + Append( + ref uint64, // the series, or 0 (new) + v float64, /* sample, value */ + ) (uint64, error) +} + +type memAppender struct{} + +func (a *memAppender) Append(ref uint64, v float64) (uint64, error) { return ref, nil } + +type Putter interface { + Put(key, value []byte) error +} + +type boltStore struct{} + +func (s *boltStore) Put(k []byte, v []byte) error { return nil } + +type Conn struct{} + +type Dialer interface { + Dial() (*Conn, error) +} + +type bridgeDialer struct{} + +func (b *bridgeDialer) Dial() (*Conn, error) { return nil, nil } + +type rwTimeoutDialer struct{} + +func (d *rwTimeoutDialer) Dial(network, address string) (*Conn, error) { return nil, nil } +`, + // Commas inside brackets, function types and struct tags are not parameters. + 'generic/visit.go': `package generic + +type Pair[K comparable, V any] struct { + Key K + Val V +} + +type Visitor interface { + Visit(fn func(key, value string) error, p Pair[string, int]) (n int, err error) +} + +type walker struct{} + +func (w *walker) Visit(f func(k, v string) error, p Pair[string, int]) (int, error) { return 0, nil } + +type lister struct{} + +func (l *lister) Visit(f func(Pair[string, int]) bool) bool { return false } + +type Tagger interface { + Tag(v struct { + A, B int \`json:"a,omitempty" yaml:"(b)"\` + }) string +} + +type tagger struct{} + +func (t tagger) Tag(v struct { + A, B int \`json:"a,omitempty" yaml:"(b)"\` +}) string { + return "" +} +`, + // A generated gRPC service: the client interface, its stub, the server + // interface and the unimplemented server. + 'api/kv_grpc.pb.go': `// Code generated by protoc-gen-go-grpc. DO NOT EDIT. + +package api + +import ( + "context" + + "google.golang.org/grpc" +) + +type RangeRequest struct{} +type RangeResponse struct{} +type ListRequest struct{} +type KV_ListClient struct{} +type KV_ListServer struct{} +type KV_WatchClient struct{} +type KV_WatchServer struct{} + +type KVClient interface { + Range(ctx context.Context, in *RangeRequest, opts ...grpc.CallOption) (*RangeResponse, error) + List(ctx context.Context, in *ListRequest, opts ...grpc.CallOption) (KV_ListClient, error) + Watch(ctx context.Context, opts ...grpc.CallOption) (KV_WatchClient, error) +} + +type kVClient struct { + cc grpc.ClientConnInterface +} + +func (c *kVClient) Range(ctx context.Context, in *RangeRequest, opts ...grpc.CallOption) (*RangeResponse, error) { + return nil, nil +} + +func (c *kVClient) List(ctx context.Context, in *ListRequest, opts ...grpc.CallOption) (KV_ListClient, error) { + return KV_ListClient{}, nil +} + +func (c *kVClient) Watch(ctx context.Context, opts ...grpc.CallOption) (KV_WatchClient, error) { + return KV_WatchClient{}, nil +} + +type KVServer interface { + Range(context.Context, *RangeRequest) (*RangeResponse, error) + List(*ListRequest, KV_ListServer) error + Watch(KV_WatchServer) error +} + +type UnimplementedKVServer struct{} + +func (UnimplementedKVServer) Range(context.Context, *RangeRequest) (*RangeResponse, error) { + return nil, nil +} +func (UnimplementedKVServer) List(*ListRequest, KV_ListServer) error { return nil } +func (UnimplementedKVServer) Watch(KV_WatchServer) error { return nil } +`, + 'server/kv.go': `package server + +import ( + "context" + + "example.com/app/api" + "google.golang.org/grpc" +) + +// Serves the KV service: a unary, a server-streaming and a bidi RPC. +type kvServer struct{} + +func (s *kvServer) Range(ctx context.Context, r *api.RangeRequest) (*api.RangeResponse, error) { + return nil, nil +} +func (s *kvServer) List(r *api.ListRequest, stream api.KV_ListServer) error { return nil } +func (s *kvServer) Watch(stream api.KV_WatchServer) error { return nil } + +// What the server ranges through. The generated client has a Range too, with +// call options: it is no RaftKV. +type RaftKV interface { + Range(ctx context.Context, r *api.RangeRequest) (*api.RangeResponse, error) +} + +// An interface that mixes a client call with a method of its own is no +// gRPC client: what it asks for is what it takes. +type closingRanger interface { + Range(ctx context.Context, in *api.RangeRequest, opts ...grpc.CallOption) (*api.RangeResponse, error) + Close() error +} + +type rangeCloser struct{} + +func (c *rangeCloser) Range(ctx context.Context, r *api.RangeRequest) (*api.RangeResponse, error) { + return nil, nil +} +func (c *rangeCloser) Close() error { return nil } +`, + // A hand-written client wrapper with methods of the same names and its own + // shapes: neither a client stub nor a server. + 'client/kv.go': `package client + +import ( + "context" + + "example.com/app/api" +) + +type kv struct{ remote api.KVClient } + +func (k *kv) Range(key string) string { return key } +func (k *kv) List(prefix string) []string { return nil } +func (k *kv) Watch(key string) <-chan string { return nil } + +func (k *kv) Get(ctx context.Context, key string) { + k.remote.Range(ctx, &api.RangeRequest{}) +} +`, + // grpc-go's xDS TransportBuilder: more balancer builders with a Build of + // their own than the cap, sorted before the one real implementer. + 'balancer/builders.go': [ + 'package balancer', + '', + 'type ClientConn struct{}', + 'type BuildOptions struct{}', + 'type Balancer struct{}', + '', + ...Array.from({ length: BALANCERS }, (_, i) => { + const n = `builder${String(i).padStart(2, '0')}`; + return `type ${n} struct{}\n\nfunc (${n}) Build(cc ClientConn, opts BuildOptions) Balancer { return Balancer{} }\n`; + }), + ].join('\n'), + 'xds/transport.go': `package xds + +type Transport struct{} + +type TransportBuilder interface { + Build(serverURI string) (*Transport, error) +} + +type grpcTransportBuilder struct{} + +func (b *grpcTransportBuilder) Build(serverURI string) (*Transport, error) { return nil, nil } +`, +}; + +let root = ''; +let cg: CodeGraph; + +beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-go-implements-arity-')); + 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?.destroy(); + if (root) fs.rmSync(root, { recursive: true, force: true }); +}); + +const meta = (e: Edge): Record => (e.metadata ?? {}) as Record; +const pkg = (n: Node | null): string => `${path.posix.dirname(n?.filePath ?? '?')}.${n?.name}`; + +/** The one non-import node with this name in this file. */ +function one(name: string, file: string): Node { + const found = cg.getNodesByName(name).filter((n) => n.filePath === file && n.kind !== 'import'); + expect(found, `${name} in ${file}`).toHaveLength(1); + return found[0]!; +} + +/** Every synthesized struct → interface edge, as `pkg.Struct -> pkg.Interface`. */ +function goImplements(): string[] { + const structs = cg.getNodesByKind('struct').filter((n) => n.language === 'go'); + return cg + .getOutgoingEdgesFrom(structs.map((n) => n.id), ['implements']) + .filter((e) => meta(e).synthesizedBy === 'go-implements') + .map((e) => `${pkg(cg.getNode(e.source))} -> ${pkg(cg.getNode(e.target))}`) + .sort(); +} + +/** Where a call through an interface method lands: its interface-impl targets. */ +function dispatch(iface: string, file: string, method: string): string[] { + const m = cg + .getOutgoingEdgesFrom([one(iface, file).id], ['contains']) + .map((e) => cg.getNode(e.target)) + .find((n) => n?.name === method)!; + return cg + .getOutgoingEdgesFrom([m.id], ['calls']) + .filter((e) => meta(e).synthesizedBy === 'interface-impl') + .map((e) => cg.getNode(e.target)!.qualifiedName) + .sort(); +} + +describe('Go implicit satisfaction checks parameter and result counts', () => { + it('links only the structs whose methods take and return as many values', () => { + expect(goImplements()).toEqual([ + 'api.UnimplementedKVServer -> api.KVClient', + 'api.UnimplementedKVServer -> api.KVServer', + 'api.UnimplementedKVServer -> server.RaftKV', + 'api.kVClient -> api.KVClient', + 'generic.tagger -> generic.Tagger', + 'generic.walker -> generic.Visitor', + 'rafthttp.Transport -> rafthttp.peerGetter', + 'server.kvServer -> api.KVClient', + 'server.kvServer -> api.KVServer', + 'server.kvServer -> server.RaftKV', + 'server.rangeCloser -> server.RaftKV', + 'storage.boltStore -> storage.Putter', + 'storage.bridgeDialer -> storage.Dialer', + 'storage.memAppender -> storage.Appender', + 'testutil.tmpDir -> testutil.Closer', + 'xds.grpcTransportBuilder -> xds.TransportBuilder', + ]); + }); + + it('dispatches a call through an interface only to methods Go could reach', () => { + expect(dispatch('peerGetter', 'rafthttp/http.go', 'Get')).toEqual(['Transport::Get']); + expect(dispatch('Closer', 'testutil/closer.go', 'Close')).toEqual(['tmpDir::Close']); + // The generated client ranges with call options; RaftKV doesn't take them. + expect(dispatch('RaftKV', 'server/kv.go', 'Range')).toEqual([ + 'UnimplementedKVServer::Range', + 'kvServer::Range', + 'rangeCloser::Range', + ]); + }); + + it('keeps a struct that fails the counts out of the per-interface cap', () => { + const builders = cg + .getIncomingEdgesTo([one('TransportBuilder', 'xds/transport.go').id], ['implements']) + .filter((e) => meta(e).synthesizedBy === 'go-implements') + .map((e) => pkg(cg.getNode(e.source))); + expect(builders).toEqual(['xds.grpcTransportBuilder']); + }); +}); + +describe('a gRPC client interface reaches the servers of its service', () => { + it('links the client stub and every server, unary and streaming RPCs alike', () => { + for (const rpc of ['Range', 'List', 'Watch']) { + expect(dispatch('KVClient', 'api/kv_grpc.pb.go', rpc), rpc).toEqual([ + `UnimplementedKVServer::${rpc}`, + `kVClient::${rpc}`, + `kvServer::${rpc}`, + ]); + } + expect( + cg + .getIncomingEdgesTo([one('KVClient', 'api/kv_grpc.pb.go').id], ['implements']) + .map((e) => pkg(cg.getNode(e.source))) + .sort() + ).toEqual(['api.UnimplementedKVServer', 'api.kVClient', 'server.kvServer']); + }); + + it('leaves out a wrapper with methods of the same names and other shapes', () => { + const wrapper = cg + .getOutgoingEdgesFrom([one('kv', 'client/kv.go').id], ['implements']) + .map((e) => pkg(cg.getNode(e.target))); + expect(wrapper).toEqual([]); + }); + + it('takes server arities only for an interface whose every method is a client call', () => { + const rangers = cg + .getIncomingEdgesTo([one('closingRanger', 'server/kv.go').id], ['implements']) + .map((e) => pkg(cg.getNode(e.source))); + expect(rangers).toEqual([]); + }); +}); + +describe('goSignatureArity', () => { + it.each([ + ['()', '0/0'], + ['() error', '0/1'], + ['(s string) (int)', '1/1'], + ['(a, b int) (n int, err error)', '2/2'], + ['(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[WatchRequest, WatchResponse], error)', '2/2'], + ['(fn func(a, b int) (int, error), m map[string][]int)', '2/0'], + ['(p Pair[K, V]) func() (int, error)', '1/1'], + ['(\n\tref uint64, // the series, or 0 (new)\n\tv float64, /* sample, value */\n) (uint64, error)', '2/2'], + ['(v struct {\n\tA, B int `json:"a,omitempty" yaml:"(b)"`\n}) string', '1/1'], + ["(r rune) bool", '1/1'], + ])('reads %j as %s', (signature, arity) => { + expect(goSignatureArity(signature)).toBe(arity); + }); + + it.each([[undefined], [''], ['(a int'], ['[T any](x T)'], ['(a int) (b int'], ['(a int) (b int) c']])( + 'gives up on %j', + (signature) => { + expect(goSignatureArity(signature)).toBeNull(); + } + ); +}); diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts index ebecb41a8..342c3f5ad 100644 --- a/src/resolution/callback-synthesizer.ts +++ b/src/resolution/callback-synthesizer.ts @@ -802,11 +802,100 @@ const IFACE_OVERRIDE_LANGS = new Set([ 'java', 'kotlin', 'csharp', 'typescript', 'javascript', 'swift', 'scala', 'go', 'rust', 'arkts', ]); +/** + * The top-level items of the Go bracketed list opening at `text[open]` — the + * parameters of `(ctx context.Context, a, b int)` are `ctx context.Context`, `a` + * and `b int` — and the offset where the list closes. Nested brackets, string + * literals and comments are skipped whole. Null when the list never closes. + */ +function goListItems(text: string, open: number): { items: string[]; end: number } | null { + const items: string[] = []; + let depth = 0; + let item = ''; + for (let i = open; i < text.length; i++) { + const c = text[i]!; + if (c === '/' && (text[i + 1] === '/' || text[i + 1] === '*')) { + const end = text[i + 1] === '/' ? text.indexOf('\n', i) : text.indexOf('*/', i + 2) + 1; + if (end <= 0) return null; + i = end; + item += ' '; + continue; + } + if (c === '"' || c === '`' || c === "'") { + let j = i + 1; + while (j < text.length && text[j] !== c) j += c !== '`' && text[j] === '\\' ? 2 : 1; + if (j >= text.length) return null; + item += text.slice(i, j + 1); + i = j; + continue; + } + if (c === '(' || c === '[' || c === '{') { + if (depth++ === 0) continue; + } else if (c === ')' || c === ']' || c === '}') { + if (--depth === 0) { + if (item.trim()) items.push(item.trim()); + return { items, end: i }; + } + } else if (c === ',' && depth === 1) { + if (item.trim()) items.push(item.trim()); + item = ''; + continue; + } + item += c; + } + return null; +} + +/** + * A Go signature as extraction stores it for a function or method — the + * parameter list's text, then the result's — split into its parameters and + * results: `(ctx context.Context, a, b int) (int, error)` has the parameters + * `ctx context.Context`, `a` and `b int`. Null when the text has another shape. + */ +function goSignatureParts(signature: string | undefined): { params: string[]; results: string[] } | null { + const sig = signature?.trim(); + if (!sig || sig[0] !== '(') return null; + const params = goListItems(sig, 0); + if (!params) return null; + const rest = sig.slice(params.end + 1).trim(); + if (rest[0] !== '(') return { params: params.items, results: rest ? [rest] : [] }; + const results = goListItems(rest, 0); + if (!results || rest.slice(results.end + 1).trim()) return null; + return { params: params.items, results: results.items }; +} + +/** + * How many parameters and results a Go signature has, as `params/results`: + * `(ctx context.Context, keys ...string) (int, error)` is `2/2`, and a grouped + * `a, b int` is two parameters. Null when the signature doesn't read. + */ +export function goSignatureArity(signature: string | undefined): string | null { + const parts = goSignatureParts(signature); + return parts ? `${parts.params.length}/${parts.results.length}` : null; +} + +/** + * The arity a gRPC server implements an RPC with, read off the signature the + * generated client has for it. A unary call (`Range(ctx, in *RangeRequest, + * opts ...grpc.CallOption) (*RangeResponse, error)`) is served without the + * call options (`Range(ctx, *RangeRequest) (*RangeResponse, error)`); a + * streaming one (`Watch(ctx, opts ...grpc.CallOption) (Watch_WatchClient, + * error)`) gets its stream in their place and returns an error + * (`Watch(Watch_WatchServer) error`). Null when it isn't a client's. + */ +function goGrpcServerArity(signature: string | undefined): string | null { + const parts = goSignatureParts(signature); + const opts = parts?.params[parts.params.length - 1]; + if (!parts || !opts || !/^(?:[A-Za-z_]\w*\s+)?\.\.\.\s*(?:grpc\.)?CallOption$/.test(opts)) return null; + const unary = parts.results.length === 2 && parts.results[0]!.startsWith('*'); + return `${parts.params.length - 1}/${unary ? 2 : 1}`; +} + /** * Go implicit interface satisfaction (#584). Go has no `implements` keyword — a * struct satisfies an interface structurally when its method set covers the * interface's. Synthesize the missing `implements` edge (struct → interface) by - * matching method-NAME sets, so impl-navigation works and the interface-dispatch + * matching method sets, so impl-navigation works and the interface-dispatch * bridge ({@link interfaceOverrideEdges}, now 'go'-enabled) can link an interface * method call to the concrete overrides. * @@ -820,12 +909,19 @@ const IFACE_OVERRIDE_LANGS = new Set([ * never followed (this pass writes them), and an embedding that resolved to * nothing (`io.Closer`) adds nothing. * - * Name-only matching (signatures ignored, and a promoted name counted even - * where Go finds it ambiguous or hidden by a field) — over-approximation - * accepted, in line with the other dispatch synthesizers; capped per - * interface. Empty interfaces (`any`) are skipped so they don't match every - * struct, and a struct that embeds the interface keeps the edge its embedding - * already is. + * Each wanted method also needs a declaration with its parameter and result + * counts, read off the stored signatures: Go wants the signatures identical, + * and the counts are what the text settles for certain — a cache's `Get(ctx, + * key, opts ...OpOption)` is no `Get(id types.ID) Peer`. A signature that + * doesn't read rules nothing out. A gRPC client interface also takes the + * structs serving its service (see `wantedArities`), the bridge a client's + * call crosses to reach its handler. + * + * Types are not compared, and a promoted name counts even where Go finds it + * ambiguous or hidden by a field — over-approximation accepted, in line with + * the other dispatch synthesizers; capped per interface. Empty interfaces + * (`any`) are skipped so they don't match every struct, and a struct that + * embeds the interface keeps the edge its embedding already is. */ async function goImplementsEdges(queries: QueryBuilder, onYield: MaybeYield): Promise { let scanned255 = 0; @@ -861,19 +957,25 @@ async function goImplementsEdges(queries: QueryBuilder, onYield: MaybeYield): Pr return kind; }; - // Memoized: an embedded base is read once, however many types embed it. + // Memoized: an embedded base is read once, however many types embed it. Each + // method's stored signature rides along for the arity check below. const ownMemo = new Map>(); + const ownSignatures = new Map>(); const ownMethods = (id: string): Set => { let names = ownMemo.get(id); if (!names) { - names = new Set( - queries - .getOutgoingEdges(id, ['contains']) - .map((e) => queries.getNodeById(e.target)) - .filter((n): n is Node => !!n && n.kind === 'method') - .map((n) => n.name), - ); + names = new Set(); + const signatures = new Map(); + for (const e of queries.getOutgoingEdges(id, ['contains'])) { + const n = queries.getNodeById(e.target); + if (!n || n.kind !== 'method') continue; + names.add(n.name); + const list = signatures.get(n.name); + if (list) list.push(n.signature); + else signatures.set(n.name, [n.signature]); + } ownMemo.set(id, names); + ownSignatures.set(id, signatures); } return names; }; @@ -906,22 +1008,29 @@ async function goImplementsEdges(queries: QueryBuilder, onYield: MaybeYield): Pr return targets; }; - // Own methods plus every embedded type's, down to the last level. Go allows - // a struct to embed a pointer to itself, or to one embedding it back. - const methodSet = (id: string): Set => { - if (embeds(id).length === 0) return ownMethods(id); - const names = new Set(); + // The type and every type it embeds, down to the last level. Go allows a + // struct to embed a pointer to itself, or to one embedding it back. + const embedClosure = (id: string): string[] => { + if (embeds(id).length === 0) return [id]; const reached = new Set([id]); const pending = [id]; while (pending.length > 0) { const at = pending.pop()!; - for (const m of ownMethods(at)) names.add(m); for (const t of embeds(at)) { if (reached.has(t)) continue; reached.add(t); pending.push(t); } } + return [...reached]; + }; + + // Own methods plus every embedded type's. + const methodSet = (id: string): Set => { + const types = embedClosure(id); + if (types.length === 1) return ownMethods(id); + const names = new Set(); + for (const t of types) for (const m of ownMethods(t)) names.add(m); return names; }; @@ -933,6 +1042,77 @@ async function goImplementsEdges(queries: QueryBuilder, onYield: MaybeYield): Pr return true; }; + // Each method name in a type's method set → the arities (`params/results`) + // its declarations there have, or null when one of their signatures doesn't + // read (then the name rules nothing out). Built only for the pairs whose + // names already match. + const arityMemo = new Map | null>>(); + const methodArities = (id: string): Map | null> => { + let arities = arityMemo.get(id); + if (arities) return arities; + arities = new Map(); + for (const t of embedClosure(id)) { + ownMethods(t); + for (const [name, signatures] of ownSignatures.get(t) ?? []) { + let known = arities.get(name); + if (known === null) continue; + if (!known) arities.set(name, (known = new Set())); + for (const signature of signatures) { + const arity = goSignatureArity(signature); + if (arity === null) { + arities.set(name, null); + break; + } + known.add(arity); + } + } + } + arityMemo.set(id, arities); + return arities; + }; + // The arities an implementer may declare each of an interface's methods + // with: the interface's own. A gRPC client interface (every method takes + // `opts ...grpc.CallOption` last) also takes its service's server side, so + // the structs serving it (etcd's `kvServer` for `KVClient`) stay linked and + // a client's call keeps reaching the handler that serves it. + const wantedArities = (iface: Node): Map | null> => { + const own = methodArities(iface.id); + const wanted = new Map | null>(); + for (const t of embedClosure(iface.id)) { + for (const [name, signatures] of ownSignatures.get(t) ?? []) { + let arities = wanted.get(name); + if (!arities) wanted.set(name, (arities = new Set(own.get(name)))); + for (const signature of signatures) { + const served = goGrpcServerArity(signature); + if (served === null) return own; + arities.add(served); + } + } + } + return wanted; + }; + + // Go accepts an implementation only when each method's signature is the + // interface's. The stored text settles how many parameters and results + // there are: a struct with no declaration of a wanted name at a wanted + // arity can't satisfy it. + const aritiesFit = (structId: string, wanted: Map | null>): boolean => { + const have = methodArities(structId); + for (const [name, arities] of wanted) { + const got = have.get(name); + if (!arities || !got) continue; + let fits = false; + for (const arity of arities) { + if (got.has(arity)) { + fits = true; + break; + } + } + if (!fits) return false; + } + return true; + }; + const structMethods = new Map>(); for (const s of goStructs) { if ((++scanned255 & 63) === 0) await onYield(); @@ -962,11 +1142,14 @@ async function goImplementsEdges(queries: QueryBuilder, onYield: MaybeYield): Pr // them: under the cap, those are what the interface-dispatch bridge links // a call through the interface to. const throughEmbedding: Node[] = []; + let wanted: Map | null> | undefined; for (const s of goStructs) { if (added >= MAX_CALLBACKS_PER_CHANNEL) break; const have = structMethods.get(s.id); if (!have || !covers(have, want)) continue; if (embeds(s.id).includes(iface.id)) continue; // declared by embedding it + if (!wanted) wanted = wantedArities(iface); + if (!aritiesFit(s.id, wanted)) continue; const own = ownMethods(s.id); if (own !== have && !covers(own, want)) throughEmbedding.push(s); else link(s);