Skip to content

fix(archtest): resolve a selector past the declaring receiver - #376

Open
OmarAlJarrah wants to merge 2 commits into
mainfrom
fix/archtest-recursion-pin-selectors
Open

fix(archtest): resolve a selector past the declaring receiver#376
OmarAlJarrah wants to merge 2 commits into
mainfrom
fix/archtest-recursion-pin-selectors

Conversation

@OmarAlJarrah

@OmarAlJarrah OmarAlJarrah commented Aug 9, 2026

Copy link
Copy Markdown
Member

Summary

loweringCallGraph in internal/archtest/recursion_test.go resolved a selector only when its base was the declaring method's own receiver — w.walkMapping inside a method on anchorWalk. 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:

  • A free function calling a method on a local it built. dynamicAnchors constructs an *anchorWalk and calls w.walk(root, "", 0); the graph showed it reaching newAnchorWalk and nothing else.
  • A free function calling a method on a parameter. lowerPathItem and lowerWebhooks take a *serviceGroups and call groups.group(...); LowerService calls groups.finalize() on a local.
  • A method calling a method on another value. anchorWalk.walkMapping reads its pairs through w.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:

  • A binding per declaration. The receiver, the parameters, the named results, a var with 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.
  • A scope per package. Each struct's named fields, the single type each declaration returns, and the node names the package holds.
  • Depth-bounded resolution. A selector chain or nested call is followed against an explicit 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/packages

Real 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

site now
dynamicAnchorsanchorWalk.walk resolved, through a local from newAnchorWalk
LowerServiceserviceGroups.finalize, lowerPathItem/lowerWebhooksserviceGroups.group resolved, through a local and through a parameter
anchorWalk.walkMappingw.view.MappingPairs deliberately not resolved. view is 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: optionsFrom writes fo.withDefaults() where fo is a type switch's binding, which this refuses to read. The same edge is recorded from Options{}.withDefaults() two lines above, so nothing is lost.

Every edge the fix adds, reviewed

This commit changes no pinned setloweringRecursions is 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:

new edge site correct?
dynamicAnchorsanchorWalk.walk schema.go:1545 yes — the walk is the whole of what dynamicAnchors does
LowerServiceserviceGroups.finalize operations.go:65 yes — groups := newServiceGroups() at :62
lowerPathItemserviceGroups.group operations.go:139 yes — a *serviceGroups parameter
lowerWebhooksserviceGroups.group operations.go:177 yes — the same, in the other caller
soleAnchorSiteAnchorIndex.sites schema.go:1340 yes — an *AnchorIndex parameter
dynamicHopAnchorIndex.sites schema.go:1414 yes — the same, in the other caller
optionsFromOptions.withDefaults openapi.go:135 yes — Options{}.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, from T{}, from &T{}, a var, 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 receiver go/parser accepts and go/types would 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:

func plantedParam(g *serviceGroups) { g.backFromParam() }
func (g *serviceGroups) backFromParam() { plantedParam(g) }

func plantedLocal() { newServiceGroups().backFromLocal() }
func (g *serviceGroups) backFromLocal() { plantedLocal() }

type plantedHolder struct{ held *serviceGroups }

func (h *plantedHolder) outThroughField() { h.held.backFromField(h) }
func (g *serviceGroups) backFromField(h *plantedHolder) { h.outThroughField() }

All three left the pin green before this change. After it, TestLoweringRecursion_IsOnlyTheKnownCycles reddens on all three at once:

--- FAIL: TestLoweringRecursion_IsOnlyTheKnownCycles (0.01s)
        	            	Diff:
        	            	--- Expected
        	            	+++ Actual
        	            	@@ -1,2 +1,2 @@
        	            	-([][]string) (len=5) {
        	            	+([][]string) (len=8) {
        	            	  ([]string) (len=27) {
        	            	@@ -40,2 +40,14 @@
        	            	  ([]string) (len=2) {
        	            	+  (string) (len=29) "plantedHolder.outThroughField",
        	            	+  (string) (len=27) "serviceGroups.backFromField"
        	            	+ },
        	            	+ ([]string) (len=2) {
        	            	+  (string) (len=12) "plantedLocal",
        	            	+  (string) (len=27) "serviceGroups.backFromLocal"
        	            	+ },
        	            	+ ([]string) (len=2) {
        	            	+  (string) (len=12) "plantedParam",
        	            	+  (string) (len=27) "serviceGroups.backFromParam"
        	            	+ },
        	            	+ ([]string) (len=2) {
        	            	   (string) (len=12) "propIDByWire",
        	Test:       	TestLoweringRecursion_IsOnlyTheKnownCycles
        	Messages:   	the lowering call graph's recursion changed; anything joining one of these sets can no longer be moved alone

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:

planted defect reddens
a selector resolved only through the declaring receiver again TestLoweringCallGraph_ResolvesAMethodOnAValue, and every positive case of TestCalleesOf_ResolvesASelectorFromDeclarations
a name bound twice kept instead of dropped …/a_name_bound_twice_to_different_types
a resolved selector recorded without checking the package declares it …/a_call_through_an_interface, …/a_promoted_method, …/a_field_of_the_receiver, …/a_field_of_a_local, and TestCalleesOf_SeparatesAValueFromWhatIsNotOne/a_selector's_field
typeNameOf reducing []T to T TestTypeNameOf_RefusesWhatIsNotATypeName/a_slice, /an_array

Full 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), not
main. It presupposes the method nodes #331 adds — without them there is nothing for
a 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.

@OmarAlJarrah
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
OmarAlJarrah force-pushed the fix/archtest-recursion-pin-selectors branch from 8d1dfc6 to f3d11f3 Compare August 9, 2026 04:23
Base automatically changed from fix/archtest-recursion-pin-methods to main August 9, 2026 11:09
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.

archtest: the recursion pin resolves a method only through its own receiver

1 participant