Skip to content

fix(go): a call through an interface reaches a method the struct gets by embedding - #2414

Merged
colbymchenry merged 5 commits into
mainfrom
claude/strange-antonelli-79c238
Oct 7, 2026
Merged

colbymchenry merged 5 commits into
mainfrom
claude/strange-antonelli-79c238

Conversation

@colbymchenry

@colbymchenry colbymchenry commented Oct 7, 2026 •

Copy link
Copy Markdown
Owner

Summary

Since #2402, goImplementsEdges counts promoted methods. A struct S{ E } satisfies an interface I when its own methods, plus those promoted from the embedded E, cover I. The interface-dispatch bridge (interfaceOverrideEdges) still linked each interface method only to a method the struct declares. When I.m is promoted from E, and E does not implement I itself, a call through I.m reached nothing. prometheus's scraper.Report never reached Target.Report through targetScraper{ *Target }, and etcd's Hasher.MemberID never reached EtcdServer.MemberID through hasherAdapter{ *EtcdServer, … }.

For a Go struct's synthesized implements edge, each interface method the struct doesn't declare now links to the method Go's selector picks:

  • Shallowest depth wins. The walk is breadth-first through the declared embeddings (the edges goImplementsEdges follows). The predicate for those edges is now shared: isGoEmbedding.
  • Ambiguous means no edge. More than one occurrence at the winning depth links nothing. That includes one type reached along two paths, which Go also rejects.
  • Shadowing. A type met at a shallower depth is not walked again.
  • An embedded interface gets no edge. It brings its whole method set at its own depth. A method it provides is a dynamic call again, so it shadows deeper structs and yields nothing.
  • Defined types (type Chain []func()) provide their own methods.
  • Struct fields are not in the graph. A field that hides a promoted method is not seen.

Edges and cap:

  • The existing edges are unchanged. Promoted links are made after the per-implementer loop, from what that struct's per-interface cap (MAX_CALLBACKS_PER_CHANNEL) has left, so every override edge keeps its slot.
  • A provider's own edge wins. When the provider implements the interface itself, its own edge already exists and is kept as is. That is 251/277 promoted pairs on prometheus and 559/570 on etcd.
  • Metadata. Edges stay provenance: 'heuristic' with synthesizedBy: 'interface-impl' and via. They add promotedInto: <struct>, and registeredAt is the wiring site: the line of the struct's embedding that brings the method in.
  • codegraph_explore labels the hop dynamic: interface → method promoted into targetScraper @scrape/scrape.go:752 instead of "runs the implementation override".
  • Declared implements edges are skipped. For a Go struct, a declared edge is an embedded interface, which provides whatever the struct lacks itself.

Validation

There are two arms: main at 31c3328 (#2402, #2407, #2401 and #2403 included) and this branch. Both use the same kernel, built from main's Rust source, on gin, prometheus and etcd (76d58e3). The nodes are identical, nothing is removed, and the only added edges are interface-impl calls.

repo Go interface-impl before → after added removed go-implements
gin 170 → 170 0 0 83, unchanged
prometheus 3366 → 3383 17 0 1829, unchanged
etcd 2607 → 2618 11 0 921, unchanged

Every added edge was triaged against the source:

  • prometheus, 14 that run:
    • scraper.Report and scraper.offset → Target's, through targetScraper, whose comment says it implements scraper.
    • SeriesSet and ChunkSeriesSet Next/Err/Warnings → blockBaseSeriesSet's, through blockSeriesSet and blockChunkSeriesSet. newBlockSeriesSet returns a storage.SeriesSet, and NewBlockChunkSeriesSet a storage.ChunkSeriesSet.
    • TSDBAdminStats.CleanTombstones/Delete/Snapshot → tsdb.DB's, through web_test.go's dbAdapter{ *tsdb.DB }, which is passed as LocalStorage. That covers web/api/v1's interface and web/api/testhelpers' copy of it, which has the same signatures.
  • prometheus, 3 that come from a name-only go-implements match: genericSeriesSet.Next/Err/Warnings → blockBaseSeriesSet's. genericSeriesSet.At() returns Labels and blockSeriesSet.At() returns Series.
  • etcd, 8 that run:
    • interfaces.Cluster.Close/WaitLeader → EtcdProcessCluster's, through e2eCluster.
    • Endpoints/WaitLeader → the integration Cluster's, through integrationCluster.
    • Hasher.MemberID/LinearizableReadNotify → EtcdServer's, through hasherAdapter.
    • version.Server.LinearizableReadNotify, through serverVersionAdapter.
    • ServerHealth.Alarms → fakeServer.Alarms, through the test's fakeHealthServer.
  • etcd, 3 that come from a name-only go-implements match:
    • Member.Start/Stop through e2eCluster. Its Client takes options, and Member.Client() doesn't.
    • WaitTime.Wait → testutil.RecorderBuffered.Wait(n int), through mockwait's waitRecorder.

codegraph_explore, before → after (same queries, both arms):

  • prometheus, scrapeLoop.scrapeAndReport scraper.Report Target.Report. The flow was scrapeAndReport → report → scraper.Report and stopped there; the only dispatch link was the test double testScraper.Report. Now it continues ↓ dynamic: interface → method promoted into targetScraper @scrape/scrape.go:752 to Target.Report (scrape/target.go:276). 88 symbols across 4 files → 75 across 2.
  • prometheus, API.series SeriesSet.Next blockBaseSeriesSet.Next. There was no flow. Now it reads series (web/api/v1/api.go:1082) → SeriesSet.Next → blockBaseSeriesSet.Next (tsdb/querier.go:588), through blockSeriesSet's embedding.
  • etcd, corruptionChecker.InitialCheck Hasher.MemberID EtcdServer.MemberID. There was no flow, and Hasher.MemberID's only dispatch link was a test double in corrupt_test.go. Now it reads InitialCheck → Hasher.MemberID → EtcdServer.MemberID (server/etcdserver/server.go:1728), through hasherAdapter.

Cost: I timed the pass alone, from each arm's build, on the same prometheus and etcd graphs, 21 alternating runs. Medians: prometheus 258 → 272 ms, etcd 206 → 221 ms. It makes about 7% more edge reads (prometheus 2074 → 2212, etcd 1548 → 1665), all of them for Go structs with a synthesized implements edge.

Known limits

  • Name-only matching is inherited from go-implements. When it links a struct to an interface the struct doesn't really satisfy, the promoted method is linked as well; 6 of the 28 added edges are like that (above).
  • promotedInto names the first struct in node order when several structs promote the same method into one interface method. ChunkSeriesSet.Next's edge names blockSeriesSet. The edge's target is the same either way.
  • Fields are not modeled. A struct field that hides a promoted method isn't seen. None of the 28 structs has one.

Overlap

#2419 (Go defined types as implementers) edits the same two functions, and whichever PR lands second resolves the conflict:

Another session (stdlib method sets for unresolved embeddings, not yet pushed) also touches goImplementsEdges, so the same one-line resolution applies there.

Tests

New __tests__/go-promoted-dispatch.test.ts, 6 tests on a fixture shaped like prometheus:

  • the promoted link, de-duplicated across two structs sharing a base;
  • Go's selector:
    • a provider two levels deep;
    • a nearer method hiding a deeper one;
    • a tie at one depth, and one type reached along two paths;
    • an embedded interface, and a deeper struct it hides;
    • a defined type;
  • a provider that implements the interface itself keeps its own edge;
  • the per-struct cap, with the struct's own method kept;
  • the codegraph_explore label;
  • a sync that adds and then removes the struct's own method.

5 of the 6 fail on main; the sixth pins the provider's own edge. The tests pass on both the kernel and the wasm (CODEGRAPH_KERNEL=0) extraction paths.

Full suite, in parallel, on this shared Windows box while other sessions held the CPU at 100%: 111 failures in 68 files.

  • All but 4 were timeouts or Windows EBUSY locks.
  • The 4 were timing-sensitive assertions: a 2 s indexing budget, a watcher state, a daemon handshake, and a test left over from an earlier timeout.
  • Rerunning those 68 files serially with --testTimeout=120000: 1571 pass, 5 fail and 7 are skipped. That run included the synthesizer, resolution, type-hierarchy, explore and framework suites.
  • 3 of the 4 files still failing pass when run alone: daemon-pid-reuse, mcp-status-freshness and mcp-writer-lock.
  • The fourth, function-ref's Caller/impact graph misses methods passed as first-class references (callbacks), e.g. executor.submit(obj.method, …) #1820 case, hits its own 60 s in-file timeout even alone. It does the same on main's source.

tsc --noEmit is clean.

🤖 Generated with Claude Code

… by embedding

Since goImplementsEdges counts promoted methods, a struct can satisfy an
interface with methods its embedded types declare. The interface-dispatch
bridge linked each interface method only to a method the struct declares
itself, so a call through such a method reached nothing when the embedded
type does not implement the interface on its own: prometheus's
scraper.Report never reached Target.Report through targetScraper.

For a Go struct's synthesized implements edge, each interface method the
struct does not declare now links to the method Go's selector picks: the
shallowest embedded type that has it, nothing when two occurrences tie at
that depth (one type reached along two paths included), and nothing when
an embedded interface provides it, which is a dynamic call again. These
links come after the per-implementer loop, from what the struct's cap has
left, so every existing edge is unchanged. They keep interface-impl
metadata plus promotedInto, and registeredAt names the struct's embedding;
codegraph_explore labels the hop as a promoted method. The embedding test
is shared with goImplementsEdges.

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