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

Merged
CO
8d ago
+257-0

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:

  • go-implements type → interface edges, so a call through IRoutes.Use still reaches both RouterGroup.Use and Engine.Use;
  • a struct's declared edge to an interface it embeds (limitAppender { Appender });
  • #2414's promoted-method links;
  • Java, TypeScript and the other languages' overrides.

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 ed199e60 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 2f2afea8, before #2417 merged, removed exactly the same edges.

reponodesedges removededges addedremoved edges that fail triage
ginidentical100
prometheusidentical11200
etcdidentical6000

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 #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:

  • A Go defined type walked as an implementer has only go-implements → interface edges, so it keeps all of them.
  • #2414's promotes already required an interface base.
  • An alias from #2417 can be an embedding target, and the guard skips it like any non-interface type. An alias of an interface owns no methods, so it linked nothing before either. Embedding an alias of a struct is embedding that struct.

Tests

  • npx tsc --noEmit: clean.
  • On this branch's head (main ed199e60 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 2f2afea8 plus this change on a loaded box: 6530 passed and 30 failed.
    • Of the 30, 26 were timeouts, 3 were EBUSY locks while removing temp dirs, and 1 was the mpeg-ts timing bound.
    • A serial rerun with 120 s timeouts left 4. Run alone, sync.test.ts passes 58/58.
    • Three still fail when run alone: daemon-pid-reuse (EBUSY at teardown), function-ref's #1820 case (its own 60 s timeout) and mpeg-ts-not-typescript (2376 ms against a 2000 ms bound). All three fail the same way on main ed199e60's own checkout; mpeg-ts is worse there, with 5822 ms and two 30 s timeouts. They come from the box, not this change.

🤖 Generated with Claude Code