fix(archtest): resolve a selector past the declaring receiver - #376
Open
OmarAlJarrah wants to merge 2 commits into
Open
fix(archtest): resolve a selector past the declaring receiver#376OmarAlJarrah wants to merge 2 commits into
OmarAlJarrah wants to merge 2 commits into
Conversation
OmarAlJarrah
changed the base branch from
main
to
fix/archtest-recursion-pin-methods
August 9, 2026 04:22
The recursion pin resolved a selector only when its base was the declaring method's own receiver, which is the one base a parser can settle without looking anything up. Every other one went unread, and three shapes follow that the tree writes today: a free function calling a method on a local it constructed (dynamicAnchors builds an *anchorWalk and calls w.walk), a free function calling one on a parameter (lowerPathItem and lowerWebhooks reach serviceGroups.group that way), and a method calling one on a field. None of them closes a cycle today, so this was an unheld invariant rather than a wrong answer. It matters because this pin is what enforces the rule that recursion is permitted only against an explicit depth counter: a recursion leaving through a free function and returning through a method was invisible to it. Resolution now runs against the declarations of the package the selector was written in — struct fields, the single type a declaration returns, and the types written on a receiver, a parameter, a named result or a var. That covers the shapes above exactly and refuses everything else: a call through an interface, a value whose type another package declares, a range variable, a type switch's binding and a name bound twice all resolve to nothing, and the selector stays unread. Matching the bare method name instead would tie every `.Ref`-style selector to whatever lowering shares its spelling, which is the resolution the one-namespace rule already refuses, and real type resolution would mean a golang.org/x/tools dependency for a test that reads four directories. A resolved selector is looked for among the nodes of the package that resolved it rather than in the graph's flat namespace, so two packages declaring one type name cannot let a field read on one match a method on the other. The pinned sets are unchanged. The seven edges the fix adds are all real call sites, and none of them closes a new cycle.
OmarAlJarrah
force-pushed
the
fix/archtest-recursion-pin-selectors
branch
from
August 9, 2026 04:23
8d1dfc6 to
f3d11f3
Compare
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.
Summary
loweringCallGraphininternal/archtest/recursion_test.goresolved a selector only when its base was the declaring method's own receiver —w.walkMappinginside a method onanchorWalk. That is the one base a parser settles without looking anything up, and every other selector went unread. Three shapes follow, and the tree writes all three:dynamicAnchorsconstructs an*anchorWalkand callsw.walk(root, "", 0); the graph showed it reachingnewAnchorWalkand nothing else.lowerPathItemandlowerWebhookstake a*serviceGroupsand callgroups.group(...);LowerServicecallsgroups.finalize()on a local.anchorWalk.walkMappingreads its pairs throughw.view.MappingPairs(n).None of these closes a cycle today, so this was an unheld invariant rather than a wrong answer. It matters because this pin is what enforces the repo's rule that recursion is permitted only against an explicit depth counter: a recursion that left through a free function and returned through a method was invisible to it, in either direction.
What it does now
Resolution runs against the declarations of the package the selector was written in, and nothing else — no type checker, no new dependency:
varwith a written type, and a short declaration whose value is a composite literal,&T{…}, or a call to a single-result function this package declares.maxResolveDepth, per the repo's bounded-recursion rule.Everything else resolves to nothing and records nothing: a call through an interface, a value whose type another package declares, a range variable, a type switch's binding, a promoted method through an embedded field, and any name bound twice to different types. That refusal is the point — matching on the bare method name instead is the resolution the one-namespace rule already refuses, and would tie every
.Ref-style selector to whatever lowering shares its spelling.A resolved selector is also looked for among the nodes of the package that resolved it, rather than in the graph's flat namespace. The type name came from one package's declarations, so the method has to as well; otherwise two packages declaring one type name would let a field read on one match a method on the other.
Why not
golang.org/x/tools/go/packagesReal resolution means types, and types mean loading the module. #210 recorded the same class of blind spot and was accepted on the pinned sets being unchanged, so that a real answer could still be told from a re-baselined one; it did not add a dependency, and neither did the method-node work. Adding one so a test can read four directories is a poor trade, and a scope built from declarations covers the shapes that exist here exactly while refusing to guess at the rest. Where the two disagree, this errs toward no edge, which is the direction that shows up as a missing member rather than a false one.
The three sites from the issue
dynamicAnchors→anchorWalk.walknewAnchorWalkLowerService→serviceGroups.finalize,lowerPathItem/lowerWebhooks→serviceGroups.groupanchorWalk.walkMapping→w.view.MappingPairsviewis a*nodeview.View; the field's type is package-qualified, so it resolves to nothing rather than to whatever this flat namespace might hold under the trailing name. No package this pin reads declares that node, so full type information would not produce an edge here either.One more site is deliberately unresolved:
optionsFromwritesfo.withDefaults()wherefois a type switch's binding, which this refuses to read. The same edge is recorded fromOptions{}.withDefaults()two lines above, so nothing is lost.Every edge the fix adds, reviewed
This commit changes no pinned set —
loweringRecursionsis byte-for-byte what #331 left it, which is the property #210 was accepted on. No previously-invisible function joined a set, because no node is new: the fix adds edges, not nodes, and none of the seven closes a cycle. Each was checked back to its call site:dynamicAnchors→anchorWalk.walkschema.go:1545dynamicAnchorsdoesLowerService→serviceGroups.finalizeoperations.go:65groups := newServiceGroups()at :62lowerPathItem→serviceGroups.groupoperations.go:139*serviceGroupsparameterlowerWebhooks→serviceGroups.groupoperations.go:177soleAnchorSite→AnchorIndex.sitesschema.go:1340*AnchorIndexparameterdynamicHop→AnchorIndex.sitesschema.go:1414optionsFrom→Options.withDefaultsopenapi.go:135Options{}.withDefaults()Grepping every method the four packages declare turns up no further call site on a value: the only one left unresolved is the type switch binding noted above, and its edge is present by the other route.
Test plan
TestCalleesOf_ResolvesASelectorFromDeclarations— planted sources for each positive shape (a local from a constructor, fromT{}, from&T{}, avar, a parameter, a field of the receiver, a field of a local, a method value) and for each refusal (an interface, a foreign package's type, a range variable, a type switch, a promoted method, a name bound twice, a call with more than one result).TestLoweringCallGraph_ResolvesAMethodOnAValue— the seven edges above, asserted against the tree rather than a planted source.TestTypeNameOf_RefusesWhatIsNotATypeName— every type form the resolver must refuse rather than reduce to an inner name, plus a receivergo/parseraccepts andgo/typeswould not.TestCalleesOf_ResolvesAMethodThroughItsReceiver,TestCalleesOf_SeparatesAValueFromWhatIsNotOne,TestDeclOf_NamesAMethodByItsReceiverType— carried forward; the planted sources now declare the methods they call, because the graph only records a node its package declares.The issue's own acceptance criterion, executed
Three recursions appended to
compilers/openapi/internal/operation/operations.go, each leaving through one shape and returning through a method:All three left the pin green before this change. After it,
TestLoweringRecursion_IsOnlyTheKnownCyclesreddens on all three at once:The plants were removed afterwards; the tree carries none of them.
The new checks were proved to bite
Each was made to fail by planting its opposite:
TestLoweringCallGraph_ResolvesAMethodOnAValue, and every positive case ofTestCalleesOf_ResolvesASelectorFromDeclarations…/a_name_bound_twice_to_different_types…/a_call_through_an_interface,…/a_promoted_method,…/a_field_of_the_receiver,…/a_field_of_a_local, andTestCalleesOf_SeparatesAValueFromWhatIsNotOne/a_selector's_fieldtypeNameOfreducing[]TtoTTestTypeNameOf_RefusesWhatIsNotATypeName/a_slice,/an_arrayFull gate passes:
gofmt,go vet ./...,golangci-lint run(0 issues),go build ./...,./scripts/check-coverage.sh(all 4942 statements covered).Note on ordering
This branch is stacked on #331, whose commit it carries: the graph has to hold method nodes before a selector can resolve to one, so the two cannot be separated. Merge #331 first and this reduces to the single commit on top of it.
Closes #323
Base branch: this PR targets
fix/archtest-recursion-pin-methods(PR #331), notmain. It presupposes the method nodes #331 adds — without them there is nothing fora resolved selector to point at — and the branch now carries exactly one commit on
top of #331's, so the diff above is this change alone. Merge #331 first.