diff --git a/CHANGELOG.md b/CHANGELOG.md index e91ee9e03..1aebcbd5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -89,6 +89,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In Go, a type alias like `type Event = mvccpb.Event`, alone or inside a `type ( … )` group, is now indexed and links to the type it names, so impact on that type reaches the code that uses the alias. Before, aliases were missing: a use written through the alias's package, like `clientv3.Event` or prometheus's `storage.AOptions{…}`, linked to nothing, and one inside the package, like `&PrometheusVersion{}` in prometheus's `web` package, linked to whichever type of that name the project had, there a test helper's struct. A method called on a value of an alias's type now reaches the aliased type's method rather than a namesake's, and a generic alias like `type Items[T any] = List[T]` links to `List`, so `xs.Len()` on one reaches `List.Len` instead of nothing. Re-index Go projects after upgrading. - In Go, a struct that embeds another is no longer treated as overriding the methods it redeclares. gin's `Engine` embeds `RouterGroup` and has a `Use` of its own, but calling `Use` on a `RouterGroup` only ever runs `RouterGroup.Use`; before, `codegraph_explore`'s call path and impact went on from it into `Engine.Use` and the code only the engine runs. The same held for every struct or defined type embedded in another that redeclares one of its methods, like etcd's `Mutex` inside `lockerMutex`. A call through an interface still reaches each type that implements it, the embedding struct included. Re-index Go projects after upgrading. - In Go, a type defined from other types, like etcd's `type WatchChan <-chan WatchResponse` or gin's `type HandlerFunc func(*Context)` and `type HandlersChain []HandlerFunc`, now links to the types it is built from, so impact on `WatchResponse` or gin's `Context` reaches these types and the code that uses them. Before, such a type linked to nothing. One written through another package, like etcd's `type PutResponse pb.PutResponse`, links to that package's type, and a recursive one like `type stateFn func(*Lexer) stateFn` doesn't link to itself. Re-index Go projects after upgrading. +- In Go, a struct or defined type 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. - In Go, a call or type written through an import whose path ends in a version, or in something other than the package's name, is now known to go through that import: `yaml.Unmarshal(…)` after `import "go.yaml.in/yaml/v3"` or `"gopkg.in/yaml.v3"`, `sqlite3.Error` after `import "github.com/mattn/go-sqlite3"`, `klog.V(2)` after `import "k8s.io/klog/v2"`. Before, only the last part of the path named such an import (`v3`, `yaml.v3`, `go-sqlite3`), so the name was matched on its own and could link to any project function, method or type that shared it: kubernetes' `klog.V(…)` calls were linked to a logging wrapper's `V` method, and etcd's `semver.Version` parameters to an unrelated `Version` function. A name from another module now links to nothing, and one through a package of your own project, like `kit.New()` after `import "example.com/kit/v2"`, links to that package's symbol. A comment in an import block is also no longer taken for the name of the import after it. Re-index Go projects after upgrading. Thanks @danusha2345 for the comment fix. (#2374) 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 bc0fcc8bb..72704a076 100644 --- a/src/resolution/callback-synthesizer.ts +++ b/src/resolution/callback-synthesizer.ts @@ -813,11 +813,100 @@ function isGoEmbedding(e: Edge, kindOf: (id: string) => NodeKind | null): boolea const to = kindOf(e.target); return to !== null && (kindOf(e.source) !== 'interface' || to === 'interface'); } +/** + * 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 * type satisfies an interface structurally when its method set covers the * interface's. Synthesize the missing `implements` edge (type → 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, and to the methods embedding promotes. * @@ -842,12 +931,19 @@ function isGoEmbedding(e: Edge, kindOf: (id: string) => NodeKind | null): boolea * 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 - * type, 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 type, 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; @@ -877,19 +973,25 @@ async function goImplementsEdges(queries: QueryBuilder, onYield: MaybeYield): Pr for (const i of goInterfaces) typeKind.set(i.id, 'interface'); const kindOf = (id: string): NodeKind | null => typeKind.get(id) ?? null; - // 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; }; @@ -912,22 +1014,29 @@ async function goImplementsEdges(queries: QueryBuilder, onYield: MaybeYield): Pr } const embeds = (id: string): string[] => embedMemo.get(id) ?? NO_EMBEDS; - // 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; }; @@ -939,6 +1048,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 type with no declaration of a wanted name at a wanted arity + // can't satisfy it. + const aritiesFit = (typeId: string, wanted: Map | null>): boolean => { + const have = methodArities(typeId); + 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; + }; + // A type without a method satisfies no interface this pass looks at. const candidates: Node[] = []; const candidateMethods = new Map>(); @@ -973,11 +1153,14 @@ async function goImplementsEdges(queries: QueryBuilder, onYield: MaybeYield): Pr // of them, every defined type among 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 candidates) { if (added >= MAX_CALLBACKS_PER_CHANNEL) break; const have = candidateMethods.get(s.id)!; if (!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);