Skip to content

fix(go): a call through a type assertion reaches the asserted type's method - #2444

Merged
colbymchenry merged 5 commits into
mainfrom
claude/eloquent-visvesvaraya-736b75
Oct 7, 2026
Merged

colbymchenry merged 5 commits into
mainfrom
claude/eloquent-visvesvaraya-736b75

Conversation

@colbymchenry

Copy link
Copy Markdown
Owner

Problem

A Go call through a type assertion, like srv.(KVServer).Range(ctx, in), reaches the resolver by its bare member name (Range), recorded at the column where its receiver expression starts, the way every call through an expression is. Resolution matched the name alone:

  • every gRPC handler protoc-gen-go-grpc emits went to the Unimplemented…Server stub beside the server interface (etcd's api/etcdserverpb/rpc_grpc.pb.go:223 → UnimplementedKVServer::Range, exact-match 0.7; 72 such edges in etcd, 3 in kratos, 95 in grpc-go);
  • other assertion calls went to a namesake (cfg.ServerFeatureGate.(featuregate.MutableFeatureGate).Set(…) → v2store's Store::Set; stream.(grpc.ClientStream).SendMsg(…) → an example's wrappedStream) or to nothing (p.(Pausable).Pause(), srv.(LockServer).Lock(…), c.Reader.(*pipe).Close()).

Fix

matchGoAssertedCall (name-matcher) runs early in resolveOneInner, ahead of the framework, import and name strategies and of the built-in filter (which dropped a method named close or copy). It reads the call's chain back from its column: the operand, then each selector, assertion, call and index, past strings, runes and comments, and onto the next line after a trailing ., where Go inserts no semicolon. When the link of the call's name follows an assertion, the call is a method of the asserted type, found where Go finds it:

A type found in none of those (outside the project, like http.Flusher, or predeclared, like error), an alias of an outside type (type Ctx = context.Context) and a type literal (interface{ Flush() }) link nothing. Two calls of one name in one chain (b.(*Builder).Add(1).Add(2)) share the column and can't be told apart; both are taken for the one made through the assertion, whose edge is real either way.

Kernel: no change needed. codegraph-kernel/src/go.rs extract_call falls to its _ arm for a type_assertion_expression receiver exactly as the TS extractor's final else does, so both emit the bare name at the call's start. The new test pins that on both backends (LF and CRLF), and the two paths' indexes agree on all 298 assertion sites in etcd and grpc-go (ref names, columns and statuses, edge targets).

Validation

Regression test: __tests__/go-type-assertion-calls.test.ts, 18 tests. The extraction check runs on the kernel and wasm; the indexed module runs on the default path and on wasm. Namesakes sit where name matching looked first: a stub beside each interface, a same-file type with each method name, and an alpha package that sorts ahead of storage. With main's resolver, 12 of the 14 resolution tests fail (6 per backend); the extraction checks and the guard that a later link of the chain is left alone pass there by design. Each guarded piece was mutation-checked: dropping dot-import lookup, string skipping, the hook's place ahead of the built-in filter, the null for a type the package doesn't declare, the alias check, or leaving two same-name links of a chain to name matching each turns a test red.

Before/after edge diffs (site-keyed on (source, line, column, ref name), main ed199e6 vs this branch, same kernel; every changed site triaged against the source: the type asserted at the call, the package Go finds it in from go.mod and the file's imports, and each side's target):

repo changed call sites retargeted added dropped same target, metadata only
etcd 117 99 9 1 8
grpc-go 158 123 2 1 32
harbor 19 0 0 7 12
kratos 6 3 0 0 3

Flows stay connected. On main, the stub-bound handler calls reached implementations through the name-only go-grpc-stub-impl bridge; now they land on the interface method and ride interface-impl dispatch (_KV_Range_Handler → KVServer.Range → kvServer.Range in v3rpc/key.go in codegraph_explore's Flow). For every moved site I compared the implementations its target dispatches to in one hop: those reached before but not after are 415 on etcd and 18 on grpc-go, and none is an implementer of the interface the call lands on. They are clients (retryKVClient, Election), server-to-client adapters (kvs2kvc, es2ec), printers, mocks and EtcdServer (still reached one hop later, through kvServer); none embeds an Unimplemented…/Unsafe…Server or has the interface's method set.

Cost: with each file's lines loaded (the resolver's other Go checks read the same lines), the check costs about what reading the ref's line does: 5–70 ms per pass over every Go call ref of these repos (13k–97k refs).

Suite: the box ran at 100% CPU with 100–220 other node processes throughout. In parallel, 104 of 6,684 tests failed (100 timeouts, 2 EBUSY/EEXIST teardowns, a writer-lock state check and a parse-time bound). A rerun of those 40 files with 3 workers and long timeouts left 3 (daemon-pid-reuse EBUSY, function-ref's Python-only #1820 case against its own 60 s limit, mcp-writer-lock), and those 3 files pass alone. tsc is clean.

Not in this PR

  • A local bound from an assertion, v, ok := x.(T) then v.M(), still has no inferred type: Go's receiver patterns read := composite literals, var and parameters, not assertions. About 90 such locals across these four repos (49 in grpc-go).
  • A defined type over an interface (type MyServer pb.KVServer) has that interface's methods; an assertion to it finds no method of its own and links nothing. Not seen in these repos.

🤖 Generated with Claude Code

colbymchenry and others added 5 commits October 7, 2026 07:06
…method

Both extractors record `srv.(KVServer).Range(ctx, in)` as a bare `Range`
call at the column where its receiver expression starts, as they record
any call through an expression. Resolution matched the name alone, so
every handler protoc-gen-go-grpc emits went to the `UnimplementedKVServer`
stub beside the `KVServer` interface (70 edges in etcd), and calls like
`c.Reader.(*pipe).Close()`, `p.(Pausable).Pause()` or
`v.(featuregate.MutableFeatureGate).Set(…)` linked to a namesake or to
nothing.

The call's chain is now read back from its column (operand, selectors,
assertions, calls and indexes, past strings, runes and comments, and on to
the next line after a trailing `.`). When the link of the call's name
follows an assertion, the call is a method of the asserted type, found
where Go finds it:
- a bare `T` or `*T` in the call's own package, or one it dot-imports;
- a `pkg.T` in the imported project package;
- its own method, the one its interface declares, or one promoted from a
  type it embeds, through the package-scoped lookup #2361 added.

A type from outside the project (`http.Flusher`), a predeclared one
(`error`) or a type literal (`interface{ Flush() }`) links nothing. The
check runs ahead of the framework, import and name strategies, and ahead of
the built-in filter, which dropped a method named `close` or `copy` called
through an assertion. Two calls of one name in a chain share the column
(`b.(*Builder).Add(1).Add(2)`); both are taken for the one made through the
assertion, whose edge is there either way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…svaraya-736b75

# Conflicts:
#	src/resolution/index.ts
#	src/resolution/name-matcher.ts
Since #2417 a Go `type A = B` is a node, and an alias of a type from
outside the project (`type Ctx = context.Context`) makes the package-scoped
method lookup step aside so the method is found by name, as before aliases
had nodes. For a call through an assertion to such an alias,
`v.(Ctx).Done()`, that fallback took any project method named `Done`. The
asserted type's methods are the outside type's, so the call now links
nothing. An alias of a project type (`type Keeper = storage.Store`) is
followed to the type it names, which the tests now pin as well.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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