Repository navigation
fix(go): a call through a variable declared with another package's type reaches that type's method - #2706
Merged
Conversation
…pe reaches that type's method Go receiver inference read a parameter's type only when the line patterns could: a lowercase name of the caller's own package (`s *server`) or a capitalized one (`lg Logger`). A parameter declared through a package, like `client rest.Interface`, `c clock.PassiveClock` or `t *testing.T`, had no type, so a call on it was matched by name: kubernetes' `client.Patch()` reached `RESTClient::Patch` instead of the interface, `c.Now()` an unrelated `Clock`, and every `t.Errorf(…)` a test helper's `T` interface. Only standard method names (PR 2664) and receivers named like an import (PR 2448) asked the scope reader. inferLocalReceiverType now asks the scope reader first for every Go receiver. The declaration in scope (a parameter, a result, a name grouped with others, a `var (…)` spec, a function literal's parameter) gives the named type it writes, as GoReceiverDecl.declared, ahead of the patterns, which scan back with no block scope. matchMethodCall, matchMemberFunctionRef and matchGoFieldChainCall all use it: - a project type resolves in its own package, through embedding and aliases; - a type from outside the project, a predeclared one or an alias of either (goHasNoProjectMethods) reaches none of the project's methods, and neither does a type literal; - a qualifier none of the file's imports names keeps the old lookup by name. Standard method names keep the rules PR 2664 gave them. Any other name the declared project type lacks goes on to the name strategies, as on a receiver of unread type. The block that typed only receivers named like an import is subsumed and removed, with the field it read. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…aration in scope now Exec is no standard method name, so on main it kept the 0.8 guess by the receiver's name; the declaration in scope now types it, at 0.9, to the same Query::Exec. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Oct 20, 2026
Merged
colbymchenry
added a commit
that referenced
this pull request
Oct 21, 2026
…ns put in it (#2745) A Go struct can hold a function in a field and call it through the field. Prometheus' service discovery runs every provider that way: refresh.Discovery calls d.refreshf(ctx), NewDiscovery copies refreshf from Options.RefreshF, and each provider passes its own method (RefreshF: d.refresh). Nothing answered d.refreshf, so a flow from Discovery.Run stopped at the field; #2430 rightly dropped the interface-impl edges that had stood in for it. goFuncFieldEdges, a Go-only synthesis pass, links a call x.F(...) through a func field of a struct to each function or method value put in that field: by a composite literal (keyed or positional, found through its instantiates edge), by an assignment x.F = v, or by one level of field-to-field copy (refreshf: opts.RefreshF). A value is what the resolver already linked it to, else x's method m for x.m, an interface's method for an interface value. Receivers are typed through the resolver's own Go scope reader (goLocalDeclarationAt over goLocalDecl, #2706), or by a local's composite literal, new(T), type assertion or resolved call result. Channels are keyed by package, struct and field, so a generic field name never crosses between unrelated structs; one package's test wiring links no other package's test; an edge cites a function's production wiring; generated files are not read. Edges are calls, provenance heuristic, synthesizedBy go-func-field, with field, via (package-qualified when the copied field is another package's) and registeredAt; explore, the node trail and ContextBuilder read them as "func field `Discovery.refreshf` (set from `Options.RefreshF`)". The pass sits after the Go framework passes, with a pool cost hint, and every Go file now counts as a synthesis input. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
A Go call
x.M()resolves throughx's declared type when receiver inference can read it. The line patterns behindinferLocalReceiverTyperead a parameter's type only when it is a lowercase name of the caller's own package (s *server) or starts with a capital (lg Logger). So a parameter declared through a package —client rest.Interface,c clock.PassiveClock,pod *v1.Pod,w http.ResponseWriter— had no type. The scope reader (#2448) knows every one of these declarations, but only two paths asked it: standard method names (#2664) and receivers named like an import (#2448). Every other call fell to the name strategies:client.Patch(…)on arest.Interfaceparameter went toRESTClient::Patchby a shared word (0.65), not the interface's method.c.Now()on aclock.PassiveClockwent to apimachinery's unrelatedClock::Now.t *testing.T(t.Errorf,t.Run,t.Fatalf, …) went to three test-helper interfaces namedT, by capitalizing the receiver's name (0.8).varthe patterns did type as an outside type looked the type's name up across the project: prometheus'spool := sync.Pool{…}reached chunkenc'sPool::Get, etcd'scc := &http.Client{}reachedKV::Doand a kubernetes client'sClient::Get, all at 0.9.d.Run(…)inside a closure takingd discovery.Discovererbecame a self-call of the enclosing(d *Discovery) Run.What changed
Resolver only (
src/resolution/name-matcher.ts); extraction and the kernel are untouched.inferLocalReceiverTypeasks the scope reader first for every Go receiver. A declaration in scope that writes a named type — a parameter, a result, either name ofa, b rest.Interface, avar (…)spec, a function literal's parameter — gives that type (GoReceiverDecl.declared), ahead of the patterns. A type literal (interface{ Errorf(…) },[]T,func(…)) givesnull. A declaration with no written type leaves the patterns to run as before.matchMethodCall,matchMemberFunctionRef(method values) andmatchGoFieldChainCall(x.field.M()) all use it:resolveGoMethodInPackage);goHasNoProjectMethods, built ongoWrittenType) reaches none of the project's methods, and neither does a type literal;err.Error()links to its receiver's declared type, never to one it shares a word with #2664's rules unchanged (a declared type lacking the method gives no edge, an outside package's value gives none). Any other name the declared project type lacks goes on to the name strategies, as a call on a receiver of unread type does.matchMethodCallthat typed only receivers named like an import is subsumed and removed, with thedeclaredTypefield it read.Validation
Tests. New
__tests__/go-declared-receiver.test.ts, 7 tests. Withmain's resolver (8beab37) 6 of them fail. The seventh passes there and pins what must not change: a shadowings := decoy.New()in an inner block is not typed through the parameter. Calls of standard method names on outside types (w.Header()), which #2664 already handles, are kept as guards inside the outside-type tests. Cases:var (…)spec, a function literal's parameter, embedding, an alias, a method value, a field chain;http.ResponseWriterbeside a projectResponseWriter(gin's case),*http.Requestbeside a projectWorker::WithContext,clock.PassiveClockbeside a projectClock,context.Contextand an alias of it beside a structCtx,var rw http.ResponseWriter,cl := &http.Client{}, a method value of an outside type.One existing expectation moves: #2664's
go-std-method-receiverstest pinnedquery.Exec()throughvar ( query promql.Query )at the 0.8 guess. The sameQuery::Execis now typed at 0.9 by that declaration, and the second commit updates the expectation.Full suite on this tree (Windows, native kernel,
CODEGRAPH_KERNEL_EXPECT=1,--maxWorkers=4 --minWorkers=1, other sessions' suites running at 100% CPU): 7,682 tests, 7,324 passed, 234 failed, 124 skipped. The 234 failures sit in 61 files: timeouts at the 5 s default, Windows file locks (EBUSY,EEXISTon a symlink),spawnSync git ENOENT, timing thresholds (2789 < 2000), and the expectation above. Rerunning those 61 files with--maxWorkers=3and 180 s timeouts: 2,016 tests, 2,000 passed, 0 failed, 16 skipped.tscpasses.Before / after. Full index of each repo with
main5530676 and this change on it, native kernel, same kernel binary. The branch was then synced withmain8beab37, which adds #1925 (agent-eval script), #1962 (docs) and #2592 (JS/TS bare calls; Go's reading is unchanged), none of them in Go resolution. Natural-key edge diff withvendor/excluded; a site is (caller, line, column, edge kind).Nodes are identical in every repo. Controls: express 0dee2c5 (JS: 1,135 nodes, 2,256 edges, 8,947 unresolved refs) and flask d086db8 (Python: 2,736 / 4,940 / 3,704) are byte-identical on both arms, compared by natural key.
Every changed site was classified by its receiver's declaration as the branch's scope reader reads it, independently of the resolver's verdict (a copy of the branch's build that exports
goLocalDecl), and by the package that declares the type, read fromgo.modfiles and the file's imports:storage.Appender→HistogramAppender::AppendHistogram,mvcc.TxnWrite→WriteView::DeleteRange,cmdutil.Factory→RESTClientGetter::ToRawKubeConfigLoader,v1.PersistentVolumeClaim→ObjectMeta::SetOwnerReferences;type Event = mvccpb.Event, harbor'sQuota = models.Quota) and same-named embeddings (client-go'sktesting.TContextembedsk8s.io/ktesting's, harbor's controllerArtifactembedspkg/artifact.Artifact);f.ClientSet.CoreV1()→Interface::CoreV1,chunk.Chunk.Bytes();k8s.io/mount-utilsasmount), read by hand: all right, likev1.Taint→ the API'sTaint::MatchTaintinstead of the internalpkg/apis/corecopy.On
mainthe moved sites were guesses into concrete implementations or namesakes: etcd'stxnReadWrite::DeleteRangefor anmvcc.TxnWrite, prometheus'sStringIter::Atfor achunks.Iterator, kubernetes' internalpkg/apis/coreTaint::MatchTaintfor av1.Taint.mainthey were guesses onto a project type that shares a name or a word:*testing.Tcalls → test-helperTinterfaces; 821klog.Loggercalls → etcd3'sklogWrapper; 419flag/pflag.FlagSetcalls → component-base'sflagSet; 173 more*testing.T→TContext;*http.Clientlocals → client-go'sHTTPClientand storageversion'sClient; 42testingclock.FakeClock→ a struct namedfakeClock;*http.Requestcalls (r.Context()→BaseAPI::Context,r.WithContext()→Worker::WithContext),&http.Client{}locals →common/http.Client;testing.TB→ its own look-alikeTB,&http.Client{}→KV::Do, gRPC stream aliases (type Lease_LeaseKeepAliveServer = grpc.BidiStreamingServer[…]) →chanStream::Context;*testing.B→TestSuite::Fail,t.Skip→Decbuf::Skip,sync.Pool→Pool::Get;t.Runon*testing.T→Engine::Run,pb.Nexton*testing.PB→Context::Next.The 132 others: 94 method values through a project type that has no method of that name, which
mainlinked to a function named like the member. 88 read a field the type declares (e.Withouton a*parser.AggregateExpr,ckv.CreateRevisionon amvccpb.KeyValue); 6 read a field of a go-swagger type harbor generates at build time (params.WithTag,model.AuthMode); 26 calls through an anonymous interface parameter (t interface{ Helper(); Errorf(…) }) thatmainsent to aT; 4PodsClientcalls whose interface is in a generated file the index skips (mainreached unrelated types); one closure parameter whoseGetNamecomes from an embedded outsideklog.KMetadata; and the 7runtime.Codeccalls under Limits.testing.TB,context.Context,io.Writer,http.Handler, gRPC stream aliases. Most ofmain's targets there are look-alikes (etcd's ownTBinterface), but some project types implement the interface and may be what runs:context.Context→mergeCtx,http.RoundTripper→uncancellableRT,net.Conn→grpcConn, gRPC streams → stream adapters, gin'shttp.FileSystem→OnlyFilesFS. The declared type is an outside interface, so the call gets no edge, asctx.Done()already does for a standard method name.Cost. No measurable change, on a box at 100% CPU with other sessions' suites running:
CPU is the indexing process plus its child, all threads. The branch asks the scope reader for every Go receiver, but
mainalready built its per-file index for nearly every Go file, through the standard-method-name path. CPU profiles of one etcd index per arm (every thread) put the scope reader's functions at 444 ms onmainand 427 ms on the branch. The new lookups andgoHasNoProjectMethodsadd about 70 ms, offset by less time in the line patterns and the name strategies: the watched functions total 901 ms vs 857 ms of 165–175 s sampled.Limits and follow-ups
type Codec Serializer) has the interface's methods, but the resolver doesn't give them to it.codec.Encode(…)on aruntime.Codecparameter now gets no edge in 7 kubernetes sites wheremainreached the versioningcodecstruct, which implements it, because the receiver shares that struct's name. fix(go): a call on a package-level variable or constant links through the type it is declared with #2667 left this gap by choice for package-level values. It is not proposed as a follow-up here either: kubernetes declares 4 such types (runtime.Codec,meta.List,meta.Type, one code-generator test type), and these 7 sites are the only ones in the five repos this change moves.main's guesses (above). Linking them would need interface-impl edges from outside interfaces, which have no nodes.:=local whose value comes from a call (r := newRing()) is still not typed by its callee's result; fix(go): type receivers from package-qualified parameters and call results; no guess for outside types #1954 does that, among other things.Overlap
PR #1954 (@danusha2345, open) types package-qualified parameters by extending the line pattern and stops outside types for every method name. This PR does both through the scope reader instead, which also reads grouped names,
var (…)blocks and function literal parameters, and won't take a test table's field or an outer variable for the receiver. That misreading is the risk the #2664 session recorded for #1954's stop. #1954's call-result typing (x := f()) remains its own; the two touch the same lines ofmatchMethodCall. The CHANGELOG bullet thanks @danusha2345. Closing or reworking #1954 is the maintainer's call.🤖 Generated with Claude Code