Skip to content

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

Merged
colbymchenry merged 7 commits into
mainfrom
claude/vibrant-curie-9d1da4
Oct 7, 2026
Merged

colbymchenry merged 7 commits into
mainfrom
claude/vibrant-curie-9d1da4

Conversation

@colbymchenry

@colbymchenry colbymchenry commented Oct 7, 2026 •

Copy link
Copy Markdown
Owner

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) main plain gate this PR
etcd kv.Do KVClient.Range kvServer.Range EtcdServer.Range Do → Do → KVClient.Range → kvServer.Range stops at KVClient.Range same as main
etcd lessor.Grant LeaseClient.LeaseGrant LeaseServer.LeaseGrant EtcdServer.LeaseGrant 5 hops to EtcdServer.LeaseGrant detours through grpcproxy's ls2lc adapter same as main
etcd watchGRPCStream.openWatchClient WatchClient.Watch watchServer.Watch (bidi) → watchServer.Watch no flow same as main
grpc-go runRecordRoute RouteGuideClient.RecordRoute routeGuideServer.RecordRoute (client stream) → routeGuideServer.RecordRoute no flow same as main
cosmos-sdk request_Query_Balance_0 QueryClient.Balance BaseKeeper.Balance (REST gateway) → BaseKeeper.Balance no flow same as main
grpc-go LRSClient.getOrCreateLRSStream TransportBuilder.Build Builder.Build no flow → grpctransport.Builder.Build → grpctransport.Builder.Build
grpc-go XDSClient.getOrCreateChannel TransportBuilder.Build Builder.Build no flow same same

(The plain-gate column ran on main 31c3328; main and this PR's columns were re-run on current main ed199e6 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 (ed199e6) 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.

gin prometheus etcd grpc-go cosmos-sdk
go-implements 93 → 92 1867 → 1641 928 → 638 2030 → 1671 6118 → 5135
removed (struct / defined type) 1 / 0 266 / 7 291 / 3 404 / 0 964 / 240
added (cap slots freed) 0 47 4 45 221
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 31c3328 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):
gin prometheus etcd grpc-go cosmos-sdk
signatures equal 92 → 92 1316 → 1337 526 → 527 1203 → 1229 3873 → 4043
same counts, other types 0 → 0 278 → 304 57 → 60 308 → 327 496 → 534
counts differ: gRPC client → server (kept) 0 → 0 0 → 0 102 → 44 121 → 115 627 → 549
counts differ: other 1 → 0 273 → 0 243 → 7 398 → 0 1122 → 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 31c3328, 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 Caller/impact graph misses methods passed as first-class references (callbacks), e.g. executor.submit(obj.method, …) #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

colbymchenry and others added 7 commits October 7, 2026 06:58
…t 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 <noreply@anthropic.com>
…9d1da4

# Conflicts:
#	CHANGELOG.md
#	src/resolution/callback-synthesizer.ts
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant