Files
codegraph/__tests__/go-implements-arity.test.ts
Colby Mchenry ce78b63d6e fix(go): implementing an interface needs matching parameter and result counts (#2447)
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. Defined types (#2419)
are held to the same counts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:29:41 +00:00

433 lines
14 KiB
TypeScript

/**
* 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<string, string> = {
'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<string, unknown> => (e.metadata ?? {}) as Record<string, unknown>;
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();
}
);
});