Skip to content

fix(go): a call on an embedded struct's method does not reach the embedder's method of that name - #2430

Merged
colbymchenry merged 1 commit into
mainfrom
claude/admiring-murdock-d55408
Oct 7, 2026
Merged

colbymchenry merged 1 commit into
mainfrom
claude/admiring-murdock-d55408

Conversation

@colbymchenry

Copy link
Copy Markdown
Owner

Summary

Go has no inheritance. type Engine struct { RouterGroup } embeds RouterGroup, and since #2397 that embedding is an extends edge. The interface-dispatch bridge (interfaceOverrideEdges, the interface-impl pass) read every extends edge as an override. It linked each embedded struct's method to the embedder's method of the same name, so gin got RouterGroup.Use → Engine.Use. A call on a *RouterGroup only ever runs RouterGroup.Use, so a flow or impact walk through it wrongly went on into Engine.Use and the code only the engine runs.

The bridge now skips a Go type's supertype edge unless it points at an interface. In Go only a call through an interface dispatches. These are unchanged:

The change is one guard in src/resolution/callback-synthesizer.ts.

Validation

Regression test: __tests__/go-embedding-no-override.test.ts, 5 tests.

  • On main, each of these fails on its own: a struct embedding (RouterGroup.Use), a defined type (HandlersChain.Last), a pointer embedding (*Mutex), a cross-package embedding (api.UnimplementedAuthServer), and the callee, caller and impact walks.
  • The tests for what must stay (interface dispatch, an embedded interface, Java and TypeScript overrides) pass on main and with the fix.

Before/after graph diff: gin, prometheus and etcd, indexed with main ed199e6 and with this branch. Both arms share the same kernel, and only callback-synthesizer.js differs. Every removed edge was triaged. An earlier round on main 2f2afea, before #2417 merged, removed exactly the same edges.

repo nodes edges removed edges added removed edges that fail triage
gin identical 1 0 0
prometheus identical 112 0 0
etcd identical 60 0 0

The triage checks each removed edge for all of these:

  • it is a calls edge from the interface-impl pass, with no promotedInto;
  • its source method belongs to a Go struct, never an interface;
  • the target's owner has a declared extends edge straight to that struct.

All 173 edges have this shape. No removed pair survives under another synthesizer's label: the gRPC stub bridge does not pick up etcd's UnimplementedAuthServer.Authenticate → mockAuthServer.Authenticate. After the fix, every Go interface-impl edge starts at an interface method (179 / 3345 / 2565). Examples of removed edges: RouterGroup.Use → Engine.Use, EC2SDConfig.NewDiscoverer → SDConfig.NewDiscoverer, Mutex.Lock → lockerMutex.Lock, maintenanceServer.Defragment → authMaintenanceServer.Defragment, batchTx.Unlock → batchTxBuffered.Unlock.

codegraph_explore on gin (RouterGroup.Use Engine.Use rebuild404Handlers):

  • Before: the Flow section claimed Use (routergroup.go:89) ↓ dynamic: interface → impl @gin.go:363 → Use (gin.go:363) ↓ calls → rebuild404Handlers. A router group's Use never rebuilds the engine's 404 handlers.
  • After: no Flow, which is right. Engine.Use's blast radius drops from 6 callers to 5.
  • codegraph_node RouterGroup.Use no longer lists Use (gin.go:363) [dynamic: interface → impl] among its calls.

Trade-off: prometheus's refresh flow goes silent

28 of prometheus's 112 removed edges are refresh.Discovery.refresh → <provider>.refresh, one for each service discovery that embeds *refresh.Discovery. The link is false as dispatch through embedding. But the call does happen at runtime, through a function field:

  • refresh.Discovery.refresh calls d.refreshf(ctx);
  • refresh.NewDiscovery copies opts.RefreshF into refreshf;
  • each provider passes refresh.Options{RefreshF: d.refresh} (discovery/aws/ec2.go:221 and so on).

The false edges approximated that flow. On main, explore for Discovery.Run EC2Discovery.refresh prints Run → refresh ↓ dynamic: interface → impl @discovery/aws/ec2.go:307 → refresh (ec2.go). That is the right path for the wrong reason, with the wrong wiring site. Adding Discovery.refresh to the query makes it pick uyuni's refresh instead of EC2's. With this change, no Flow is printed: the path stops at d.refreshf(ctx). Nothing in the synthesizer bridges a Go function-typed field today; fieldChannelEdges matches JS this. patterns only. A dedicated bridge would restore the flow with the real wiring site (RefreshF: d.refresh). It is filed as a follow-up and kept out of this PR.

20 of the 28 targets have a value reference into them (RefreshF: d.refresh). The other 8 are wired through a refresher interface value (RefreshF: r.refresh in hetzner, scaleway, ovhcloud and stackit) or ionos's d.refresh. No target of any other removed edge in the three repos has a value reference, so none of gin's or etcd's removed edges, and no other prometheus one, carries a call like this.

Related, not changed here

  • gin's tests call router := New(); router.Use(…). All 56 such calls resolve to RouterGroup.Use by the receiver-word guess (instance-method, 0.65); at runtime they run Engine.Use. Before this change, the false edge then led them into Engine.Use. The call-result receiver typing in open PR fix(go): type receivers from package-qualified parameters and call results; no guess for outside types #1954 may cover this.
  • go-implements matches a type to an interface with an unexported method across packages. Go only allows that through a method declared in the interface's own package. prometheus has 267 such edges (refresher {refresh} in six discovery packages, each matched to about 37 providers), feeding 264 dispatch edges. Filed as a follow-up. etcd's 27 are mustEmbedUnimplemented… gRPC interfaces, which a server in another package does satisfy by embedding the stub, so the rule must check where the method is declared.

Overlap

This branch is on top of #2414 (promoted-method dispatch), #2419 (defined types as implementers) and #2417 (type A = B aliases), all merged while it was in progress. The new guard sits inside #2419's reworked loop:

Tests

  • npx tsc --noEmit: clean.
  • On this branch's head (main ed199e6 plus this commit), 11 test files and 365 tests passed:
    • the new test, plus go-type-alias and cpp-library-type-receiver from the two PRs that merged last;
    • go-implements-embedding, go-promoted-dispatch, go-implements-defined-types, go-interface-embedding and go-type-position-kinds;
    • ui-flow-api, resolution and frameworks-integration.
  • Full suite, run on main 2f2afea plus this change on a loaded box: 6530 passed and 30 failed.

🤖 Generated with Claude Code

…edder's method of that name

Go has no inheritance. A struct that embeds another (`type Engine struct
{ RouterGroup }`) has an `extends` edge to it since #2397, and the
interface-dispatch bridge read every `extends` edge as an override, so it
linked RouterGroup.Use to Engine.Use. A call on a *RouterGroup only ever
runs RouterGroup's own Use, so a flow or impact walk through it went on
into Engine.Use and the code only the engine runs.

The bridge now skips a Go type's supertype edge unless it points at an
interface: only a call through an interface dispatches. Structs that embed
an interface keep their links, and so do the go-implements edges, so a call
through IRoutes still reaches both RouterGroup.Use and Engine.Use. Other
languages are unchanged.

Removed on gin / prometheus / etcd: 1 / 112 / 60 interface-impl edges, all
from a struct's method to a struct embedding it; nothing else in the graph
changed.

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