mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-16 20:53:57 +08:00
ce78b63d6e
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>
433 lines
14 KiB
TypeScript
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();
|
|
}
|
|
);
|
|
});
|