fix(go): implementing an interface needs matching parameter and result counts

Merged
CO
8d ago
+637-21

Summary

goImplementsEdges (Go implicit interface satisfaction, #584) matched method names only. Go wants every method's signature identical, so a type whose same-named method takes or returns a different number of values can never satisfy the interface. Name matching linked it anyway:

  • etcd's watch Cache (Get(ctx, key string, opts ...OpOption) (*GetResponse, error)) "implemented" rafthttp's peerGetter (Get(id types.ID) Peer), along with 34 other Gets;
  • prometheus's test Closer { Close() } collected 34 types whose Close returns an error;
  • on grpc-go, 38 balancer, resolver and logger builders whose Build takes other arguments filled the 40-slot cap of xDS's TransportBuilder (Build(ServerIdentifier) (Transport, error)) before the real grpctransport.Builder was reached, so codegraph_explore could not follow the xDS client into the transport it builds.

Change

  • Each wanted method now also needs a declaration, own or promoted, with the same parameter and result counts, read off the stored signature text ((params) results). Parameters are the top-level comma-separated items (a, b int is two); results are none, one bare type, or a parenthesized list. Nested brackets (Pair[K, V], func(a, b int)), string literals (struct tags) and comments are skipped. A signature that doesn't read rules nothing out; on the five repos below every Go method signature read.
  • gRPC exception. A gRPC client interface (every method takes opts ...grpc.CallOption last) also accepts its service's server arity: one parameter fewer with the same results for a unary RPC, one parameter fewer returning error for a streaming one (the client's first result is not a pointer). So KVClient.Range → kvServer.Range, which is no Go implementation but is the bridge a client's call crosses to reach its handler, stays. The generated client stub and real client wrappers keep matching by the client arity as before.
  • Types are still not compared, and a promoted name still counts even where the struct's own method of that name hides it (documented over-approximation).

Why keep the gRPC client → server pairs

They act as RPC bridges: codegraph_explore's Flow section follows a client call through the client interface into the server handler. With a plain arity gate those flows break, which is the partial-coverage case AGENTS.md warns about:

explore probe (symbol bag)mainplain gatethis PR
etcd kv.Do KVClient.Range kvServer.Range EtcdServer.RangeDo → Do → KVClient.Range → kvServer.Rangestops at KVClient.Rangesame as main
etcd lessor.Grant LeaseClient.LeaseGrant LeaseServer.LeaseGrant EtcdServer.LeaseGrant5 hops to EtcdServer.LeaseGrantdetours through grpcproxy's ls2lc adaptersame as main
etcd watchGRPCStream.openWatchClient WatchClient.Watch watchServer.Watch (bidi)→ watchServer.Watchno flowsame as main
grpc-go runRecordRoute RouteGuideClient.RecordRoute routeGuideServer.RecordRoute (client stream)→ routeGuideServer.RecordRouteno flowsame as main
cosmos-sdk request_Query_Balance_0 QueryClient.Balance BaseKeeper.Balance (REST gateway)→ BaseKeeper.Balanceno flowsame as main
grpc-go LRSClient.getOrCreateLRSStream TransportBuilder.Build Builder.Buildno flow→ grpctransport.Builder.Build→ grpctransport.Builder.Build
grpc-go XDSClient.getOrCreateChannel TransportBuilder.Build Builder.Buildno flowsamesame

(The plain-gate column ran on main 31c3328d; main and this PR's columns were re-run on current main ed199e60 with identical Flow sections.) The dedicated goGrpcStubImplEdges pass doesn't cover this: it links Unimplemented*Server stub methods to implementations, not client interfaces to servers.

A first, looser exception (accept params - 1 with any result count) let any Set(a, b) error struct match cosmos-sdk's one-method KeyValueClient; deriving the server arity per RPC kind fixed that. Requiring the interface to live in a .pb.go file changed only 3 grpc-go edges (one is the server of checked-in generated code under another name), so it isn't required.

Validation

Kit: gin (43fe48e), prometheus (39c878f), etcd (76d58e3), grpc-go (6c0c296) and cosmos-sdk (2ad20ad), each indexed by current main (ed199e60) and by this branch, same kernel. The graphs differ only in go-implements edges and the interface-impl calls that hang off them; nodes, unresolved refs and files are identical.

ginprometheusetcdgrpc-gocosmos-sdk
go-implements93 → 921867 → 1641928 → 6382030 → 16716118 → 5135
removed (struct / defined type)1 / 0266 / 7291 / 3404 / 0964 / 240
added (cap slots freed)047445221
interface-impl−1−397 +87−416−538 +57−2334 +203
  • An independent re-derivation of the rule over the main graph agrees with every changed edge: all removed pairs fail it, all added pairs pass it. On main 31c3328d the real after-sets also matched a simulation of the whole pass (cap and ordering included) edge for edge.
  • Removed — sampled per interface on all five repos; every one is a count mismatch Go would reject: peerGetter.Get (35 on etcd), Compactable.Compact (the generated kVClient.Compact with call options), WatchClient.Watch vs clientv3's Watch(ctx, key, opts...) WatchChan, printers' MemberAdd(resp), backend transactions' Lock() vs LockClient.Lock(ctx, in, opts...), prometheus Closer/AppenderV2/TBRun, grpc-go balancer builders under TransportBuilder/methodLoggerBuilder/ChannelCredentials, cosmos-sdk gomock recorders (arg0, arg1 any) *gomock.Call and the protobuf fastReflection_* types under ParamStore, HasValue, KeyValueServer. The 58 etcd gRPC-interface removals are none of them servers (the 5 kept WatchClient implementers are exactly the generated client, the stub, watchServer, watchProxy and the ws2wc adapter).
  • Added — the cap now fills with arity-plausible types: grpc-go's grpctransport.Builder and fake transport under TransportBuilder, the xDS credential builders under ChannelCredentials, prometheus discoverers under Discoverer, and some same-count/other-type matches the cap used to hide (e.g. ProducerBuilder).
  • Signature judge (exact/loose = signature text equal, up to package qualifiers):
ginprometheusetcdgrpc-gocosmos-sdk
signatures equal92 → 921316 → 1337526 → 5271203 → 12293873 → 4043
same counts, other types0 → 0278 → 30457 → 60308 → 327496 → 534
counts differ: gRPC client → server (kept)0 → 00 → 0102 → 44121 → 115627 → 549
counts differ: other1 → 0273 → 0243 → 7398 → 01122 → 9

The 16 left are a struct whose own method hides a promoted one of the same name but another shape (etcd's test integrationClient declares AuthEnable(ctx) error and embeds *clientv3.Client); the gate counts any declaration in the method set, as the name check already did.

  • Cost: the pass issues exactly the same QueryBuilder calls (counted per method on etcd, grpc-go and cosmos-sdk); the arity work only runs for pairs whose names already match. Alternating both builds in one process over the same stripped graph (15 rounds), the pass medians were etcd 253 → 266 ms, grpc-go 540 → 545, prometheus 308 → 290, cosmos-sdk 2865 → 2737 on main 31c3328d, and grpc-go 513 → 516, cosmos-sdk 4209 → 4414 on current main with a test run going: within this box's noise.

Agent A/B

Not run. The gRPC client→server flows come out byte-identical to main; the flows that changed are the two grpc-go xDS ones that now connect, plus side links in the "Dynamic-dispatch links" list (etcd's Compactable.Compact now shows a test fake instead of the client wrapper retryKVClient). A Windows port of ab-new-vs-baseline.sh (no rsync/jq here, global codegraph dropped from the arm PATH, node CLI-block hook) is ready, but every claude -p run on this machine fails with "OAuth session expired and could not be refreshed".

Tests

  • New __tests__/go-implements-arity.test.ts (22 cases): etcd/prometheus/grpc-go shapes (a Get with other parameters, a Close with a result, promoted methods, grouped names, multi-line signatures with comments, generics and struct tags, the cap-starved TransportBuilder), a generated gRPC service with unary, server-streaming and bidi RPCs reaching the client stub, the unimplemented server and the real server (not a same-named wrapper, not via a mixed interface), and goSignatureArity unit cases. All 22 fail on main and pass here, on the kernel and the wasm extraction paths.
  • All Go suites (16 files, 205 tests) pass on both paths; one wasm-path test (go-bare-call-no-method) failed once in the parallel run and passed alone, on this branch and on main.
  • Full suite on this Windows box (busy): the parallel run passed 6497 and timed out 109 tests in 42 files; re-run serially with 120 s timeouts, those 42 files passed except one function-ref.test.ts #1820 case at its own 60 s in-file limit, which passes alone here and on main.

Overlap

Merged current main (#2414 promoted dispatch, #2419 defined types, #2417 type aliases) into this branch: the gate applies to defined-type implementers too, and #2414's promoted links follow whatever go-implements edges remain.

Merged on top of

Main moved five times during the merge (#2430, #2442, #2444, #2433, #2449, #2434, #2448, #2437, #2431, #2441, #2436). Only #2430 touches this area: it drops Go struct-to-struct interface-impl edges, a set disjoint from the interface-based ones this PR changes. Re-indexing gin and etcd with the merged build differed from the earlier fix arm by exactly #2430's own removals (1 and 60 edges), so the tables above stand. #2441 adds references edges from defined types, which this pass never reads. On the merged tree (kernel rebuilt for #2441), the Go suites, including every incoming Go PR's tests, pass on both extraction paths (11 files, 168 tests).

Follow-ups (not here)

  • goGrpcStubImplEdges matches by name only as well: on etcd it links UnimplementedKVServer.Range to retryKVClient.Range and kvs2kvc.Range, client wrappers.
  • A struct's own method should hide a promoted one of the same name (16 edges above).

🤖 Generated with Claude Code