fix(resolution): type Go pointer-receiver bases at the class lookup (#2766)

A Go method with a pointer receiver binds its receiver to the literal
string `*Holder` — `synthesizeGoReceiverBinding` stores `typeNode.text`
raw. `findClassBindingInScope` normalizes exactly one decoration, a
dotted qualifier, so `*Holder` matched nothing: the scope walk missed,
the qualified-name index missed, and the dotted-tail fallback never fired
because there is no dot. Receiver typing declined at the BASE, so every
`h.field.Method()` in a pointer-receiver method — the dominant Go idiom —
emitted no CALLS edge. The reporter measured 178 handler and 585 service
call sites lost on a 470k LOC codebase.

The defect is the base ALONE. Go already normalizes field type bindings
at capture through `normalizeGoTypeName`, so the step lookup was always
sound. Isolating the two independently proves it: value receiver + value
field resolves, value receiver + POINTER field resolves, pointer receiver
+ value field does not.

Adds an OPT-IN decoration fallback to the class lookup, consulted only
after every undecorated branch has failed:

- `findAllClassBindingsInScope` enumerates every class-like candidate
  from both the scope chain and the qualified-name index. Needed because
  `walkScopeChain` returns the FIRST match and structurally cannot report
  a collision, so widening what a name matches without it would pick the
  nearest of several and mint a confident wrong edge. It stops at the
  first scope that binds the name, so an inner binding shadowing an outer
  one is not misreported as ambiguity.
- The fallback strips one layer at a time and requires exactly ONE
  surviving nodeId, or it declines.
- `stripTypePreservingDecoration` on the ScopeResolver contract carries
  the per-language vocabulary, so the core names no language (AGENTS.md
  R6). Go strips `*` only — `[]` and `map[…]` are CONTAINERS whose member
  set differs from the element's, and stripping one would let
  `repos: Repo[]` fold `repos.find(x)` to `Repo.find`. Those are unwrapped
  only by an index step that consumed a subscript.

Opt-in rather than global because ~two dozen call sites are shaped
`findClassBindingInScope(...) ?? otherResolver(...)`: turning a former
`undefined` into a hit SUPPRESSES the fallback that used to answer, which
would retarget inheritance edges and bypass generic-specialization
selection. Only receiver-chain base and step resolution opts in. The
stored `*T` binding is left decorated — `method-owners.ts` consumes the
`*T` vs `T` distinction to model Go's value and pointer method sets, so
this normalizes at LOOKUP, never by rewriting the binding.

Verification:
- bench cell `go.decoratedReceiverBase` VISIBLE-GAP -> RESOLVES, and it
  is the ONLY cell that moved of 164.
- #2766's reproduction goes from 4 CALLS edges to 10; cross-package
  interface field, cross-package concrete field and same-package field
  all resolve.
- Regression test proven to fail without the fix: with the stripper
  disabled the 3 pointer-receiver assertions fail while both controls
  (local-variable receiver, value receiver) still pass — so the test
  targets the changed line and the fix adds edges rather than moving one.
- Full resolver suite green (2988 tests).

Baseline movement, both fixture-corpus growth rather than code:
receiver-resolution callDrops unchanged at 101; scope-capture go
fingerprint rebaselined with a documented reason, and go was the only
language of 15 that drifted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergo Magyar 2026-07-31 12:12:34 +00:00
parent b442e04bf4
commit 809d2eb63b
10 changed files with 283 additions and 5 deletions

View file

@ -73,7 +73,7 @@
"explicitTypeArgs": "N/A",
"indexElement": "INVISIBLE-GAP",
"fieldReceiverCall": "RESOLVES",
"decoratedReceiverBase": "VISIBLE-GAP",
"decoratedReceiverBase": "RESOLVES",
"decoratedFieldType": "RESOLVES"
},
"javascript": {

View file

@ -1,12 +1,13 @@
{
"_comment": "Per-language baselines for bench/scope-capture/measure.mjs --check. fingerprint = order-independent sha256 over the lang-resolution/<lang>-* fixture corpus + a 20-entity synthetic source (correctness gate; re-baseline intentionally on a legitimate capture change). scaling_budget = max allowed (t800/t250)/(800/250); ~1.0 is linear, ~3.2 is quadratic. The synthetic source is now HERITAGE-BEARING for every language (each Entity extends/implements/embeds/uses-trait/conforms-to a shared base) so the #1951 @reference.inherits synth is gated at scale, not just the base capture loop. All languages thread the tree-sitter captured node instead of re-deriving it with findNodeAtRange(tree.rootNode,...) per match, so all are linear (go #1915, python #1918, ruby/php/rust/csharp #1951, java #1956).",
"go": {
"fingerprint": "5d6c59c2f2c0dd937c53bf5d736e0f8376b2899a381e488a33aec23524823efb",
"fingerprint": "8cba537ff211fab3bac5fb4456cd1ffba14d6a2db75c40acae28ab8bf29f3d2e",
"scaling_budget": 1.5,
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 3d4e32e7490c830516126e28931827949baa3594cb521f7a3d8dcfed95b6018a -> 57b3c55135af8d2af33b9a7c4bf89796a7bee5b5822b402a2dea91af7232cf4a; scaling 1.058 < 1.5.",
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: provider-owned callable assignment/copy/formal/argument/invoke facts with invocation/constructor-result suppression. Prior 09ecd94911b830f52fa8807560abcbd79f163d02a2072870c1a59297e9a326e1 -> 3d4e32e7490c830516126e28931827949baa3594cb521f7a3d8dcfed95b6018a; scaling 1.039 < 1.5.",
"_rebaselined": "#1976: F33 generic composite literal constructor inference adds generic_type captures in composite_literal patterns; fingerprint drift expected.",
"_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 57b3c55135af8d2af33b9a7c4bf89796a7bee5b5822b402a2dea91af7232cf4a -> 5d6c59c2f2c0dd937c53bf5d736e0f8376b2899a381e488a33aec23524823efb."
"_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 57b3c55135af8d2af33b9a7c4bf89796a7bee5b5822b402a2dea91af7232cf4a -> 5d6c59c2f2c0dd937c53bf5d736e0f8376b2899a381e488a33aec23524823efb.",
"_rebaselined_2766_go_pointer_receiver_fixture": "#2766: added test/fixtures/lang-resolution/go-pointer-receiver-field-chain/ (2 Go files) as the committed regression fixture for pointer-receiver base resolution. Go fixture_count 100 -> 102. Prior 5d6c59c2f2c0dd937c53bf5d736e0f8376b2899a381e488a33aec23524823efb -> 8cba537ff211fab3bac5fb4456cd1ffba14d6a2db75c40acae28ab8bf29f3d2e. FIXTURE-CORPUS GROWTH, NOT A CAPTURE CHANGE: the accompanying fix is a resolution-time lookup fallback (stripTypePreservingDecoration) and cannot move capture output; go was the ONLY language whose fingerprint drifted, and every other language matched its baseline on the same run."
},
"cobol": {
"fingerprint": "d45bb091b0893d0de4fae2486b31ba21719c9377bf35a0908fd3a36fa1c3bf4e",

View file

@ -33,6 +33,17 @@ export const goScopeResolver: ScopeResolver = {
arityCompatibility: (callsite, def) => goArityCompatibility(def, callsite),
// Only `*` — a pointer leaves the method set reachable by selector unchanged,
// so `*Host` and `Host` name the same class for receiver typing. `[]` and
// `map[…]` are deliberately NOT stripped here: they are containers whose
// member set differs from the element's, and unwrapping them belongs to the
// index step that consumed a subscript. (Field bindings never reach this
// anyway — `normalizeGoTypeName` already strips them at capture. The one
// binding that arrives decorated is the receiver self-binding, kept raw on
// purpose for `method-owners.ts`.)
stripTypePreservingDecoration: (typeName) =>
typeName.startsWith('*') ? typeName.slice(1).trim() : undefined,
buildMro: (graph, parsedFiles, nodeLookup) =>
buildMro(graph, parsedFiles, nodeLookup, defaultLinearize),

View file

@ -1114,6 +1114,39 @@ export interface ScopeResolver {
*/
readonly hoistTypeBindingsToModule?: boolean;
/**
* Strip ONE layer of type-preserving decoration off a declared type name,
* or return `undefined` when there is nothing left to strip.
*
* Exists because a declared type is stored as written. Go's
* `synthesizeGoReceiverBinding` keeps `typeNode.text`, so a pointer-receiver
* method binds its receiver to the literal `*Host` which matches no class
* binding, so receiver-chain resolution declines at the base and every
* `h.field.method()` in the dominant Go idiom loses its `CALLS` edge (#2766).
* The stored binding is deliberately left decorated (`method-owners.ts`
* consumes `*T` vs `T` to model Go's value and pointer method sets), so the
* normalization belongs at LOOKUP, never as a rewrite of the binding.
*
* TYPE-PRESERVING ONLY. Pointer, reference, `const`, nullable, borrow,
* deref-transparent smart pointer and sigil all leave the member set
* unchanged. A CONTAINER array, slice, map, `Option` does not: stripping
* one here would type `repos: Repo[]` as `Repo` and let `repos.find(x)` fold
* to `Repo.find`, a confident wrong edge the ambiguity gate cannot catch
* because `Repo` binds uniquely. Containers are unwrapped only by an index
* step that consumed a subscript.
*
* Consulted ONLY after every undecorated lookup has failed, and only by
* receiver-chain base and step resolution the shared class lookup keeps
* exact-name behaviour for its other ~two dozen callers, several of which are
* shaped `findClassBindingInScope(...) ?? otherResolver(...)` and would have
* their fallback suppressed by a global widening.
*
* Leave undefined for languages whose declared types carry no type-preserving
* decoration. Measured: only Go needs it for a receiver base; Rust, C#, Swift,
* TypeScript and C++ need it for field types.
*/
readonly stripTypePreservingDecoration?: (typeName: string) => string | undefined;
/**
* Whether the compound-receiver resolver should strip C-style cast
* expressions from receiver-position text before resolving it

View file

@ -121,6 +121,13 @@ interface ResolveCompoundReceiverOptions {
* (`const Config = make(1); Config.db.query()` emitted `entry → Database.query`),
* the exact wrong-edge failure this work exists to avoid. */
readonly strictBaseBinding?: boolean;
/** Per-language type-preserving decoration stripper, from the `ScopeResolver`
* contract. Passed to the class lookup at the base and step sites so a
* decorated declared type (`*Host`) resolves to its class. Absent for
* languages whose declared types carry no such decoration, and never applied
* by the shared lookup's other callers — see the contract's own note on why
* this is opt-in rather than global. */
readonly stripTypePreservingDecoration?: (typeName: string) => string | undefined;
}
/** Is this hop the language's construction selector applied to the class
@ -304,7 +311,12 @@ function typeOfMemberOnClass(
const classScope = classScopeByDefId.get(ownerId);
const memberType = classScope?.typeBindings.get(memberName);
if (memberType !== undefined) {
return findClassBindingInScope(memberType.declaredAtScope, memberType.rawName, scopes);
return findClassBindingInScope(
memberType.declaredAtScope,
memberType.rawName,
scopes,
options.stripTypePreservingDecoration,
);
}
// Languages whose binding-scope hook hoists a method's return-type binding
// out of the class body and onto an ancestor (Module) scope keep NOTHING in
@ -480,7 +492,12 @@ export function resolveCompoundReceiverClass(
return findClassBindingInScope(rhsTb.declaredAtScope, arg, scopes);
}
const viaTb = findClassBindingInScope(tb.declaredAtScope, tb.rawName, scopes);
const viaTb = findClassBindingInScope(
tb.declaredAtScope,
tb.rawName,
scopes,
options.stripTypePreservingDecoration,
);
if (viaTb !== undefined) return viaTb;
// Member-alias / call-result shapes store the RHS path on rawName

View file

@ -96,6 +96,7 @@ type ReceiverBoundProviderSubset = Pick<
| 'hoistTypeBindingsToModule'
| 'stripReceiverCastExpressions'
| 'constructionSyntax'
| 'stripTypePreservingDecoration'
| 'resolveQualifiedReceiverMember'
| 'resolveReceiverMember'
| 'resolveThisViaEnclosingClass'
@ -184,6 +185,7 @@ export function emitReceiverBoundCalls(
hoistTypeBindingsToModule,
stripReceiverCastExpressions: provider.stripReceiverCastExpressions === true,
constructionSyntax: provider.constructionSyntax,
stripTypePreservingDecoration: provider.stripTypePreservingDecoration,
};
// Build an interface → implementors map from IMPLEMENTS edges.

View file

@ -321,10 +321,91 @@ export function moduleScopeIdOf(
*
* Without (2) we'd miss every cross-file class-receiver call.
*/
/**
* Every class-like definition visible for `name`, from the scope chain AND the
* qualified-name index, deduped by `nodeId`.
*
* Exists because `walkScopeChain` returns the FIRST match and cannot report a
* collision, so a caller that widens what a name can match (the decoration
* normalizer below) has no way to tell "one answer" from "picked the nearest of
* several". Mirrors `findAllCallableBindingsInScope`, which solved the same
* problem for callables.
*/
export function findAllClassBindingsInScope(
startScope: ScopeId,
name: string,
scopes: ScopeResolutionIndexes,
): readonly SymbolDefinition[] {
const byNodeId = new Map<string, SymbolDefinition>();
let currentId: ScopeId | null = startScope;
const visited = new Set<ScopeId>();
while (currentId !== null) {
if (visited.has(currentId)) break;
visited.add(currentId);
const scope = scopes.scopeTree.getScope(currentId);
if (scope === undefined) break;
// `Object` scopes are a hoist boundary only — see walkScopeChain (#2545).
if (scope.kind !== 'Object') {
const found: SymbolDefinition[] = [];
for (const b of scope.bindings.get(name) ?? []) {
if (isClassLike(b.def.type)) found.push(b.def);
}
for (const b of lookupBindingsAt(currentId, name, scopes)) {
if (isClassLike(b.def.type)) found.push(b.def);
}
// Stop at the first scope that binds the name at all: an inner binding
// SHADOWS an outer one, so continuing would report a shadowed outer
// definition as a competing candidate and decline a name that is actually
// unambiguous at this point.
if (found.length > 0) {
for (const def of found) byNodeId.set(def.nodeId, def);
return [...byNodeId.values()];
}
}
currentId = scope.parent;
}
for (const id of scopes.qualifiedNames.get(name)) {
const def = scopes.defs.get(id);
if (def !== undefined && isClassLike(def.type)) byNodeId.set(def.nodeId, def);
}
return [...byNodeId.values()];
}
/**
* Strip one layer of type-preserving decoration off a declared type name, or
* `undefined` when there is nothing left to strip. Supplied per language through
* the `ScopeResolver` contract; the core never names a language (AGENTS.md R6).
*
* TYPE-PRESERVING only pointer, reference, `const`, nullable, borrow,
* deref-transparent smart pointer, sigil. A CONTAINER (array, slice, map,
* `Option`) changes the member set, so stripping one here would type
* `repos: Repo[]` as `Repo` and let `repos.find(x)` fold to `Repo.find`. Those
* are unwrapped only by an index step that consumed a subscript.
*/
export type DecorationStripper = (typeName: string) => string | undefined;
/** Bounded so a pathological stripper cannot spin. Real decoration nests
* shallowly (`*[]T`, `const T&`); three layers is generous. */
const MAX_DECORATION_LAYERS = 3;
export function findClassBindingInScope(
startScope: ScopeId,
receiverName: string,
scopes: ScopeResolutionIndexes,
/**
* OPT-IN. When supplied, a name that binds nothing is retried with decoration
* stripped one layer at a time, and each retry must resolve to exactly ONE
* class-like definition or it declines.
*
* Opt-in rather than global because roughly two dozen call sites use the shape
* `findClassBindingInScope(...) ?? otherResolver(...)`: turning a former
* `undefined` into a hit SUPPRESSES the fallback that used to answer, which
* would retarget inheritance edges and bypass generic-specialization
* selection. Only receiver-chain base and step resolution passes this.
*/
stripDecoration?: DecorationStripper,
): SymbolDefinition | undefined {
const local = walkScopeChain(startScope, receiverName, scopes, (def) => isClassLike(def.type));
if (local !== undefined) return local;
@ -350,6 +431,25 @@ export function findClassBindingInScope(
}
}
}
// Decoration fallback (opt-in). Every branch above works on the name exactly
// as written; only when none of them bound anything do we consider that the
// name may be a decorated spelling of one that would.
if (stripDecoration !== undefined) {
let current = receiverName;
for (let layer = 0; layer < MAX_DECORATION_LAYERS; layer++) {
const stripped = stripDecoration(current);
if (stripped === undefined || stripped === current || stripped.length === 0) break;
current = stripped;
const candidates = findAllClassBindingsInScope(startScope, current, scopes);
// Exactly one, or decline. Two same-named classes reachable from here mean
// the decoration was carrying the only disambiguating information, and
// picking the nearest would mint a confident wrong edge — the failure this
// whole line of work exists to avoid. A missing edge is recoverable.
if (candidates.length === 1) return candidates[0];
if (candidates.length > 1) return undefined;
}
}
return undefined;
}

View file

@ -0,0 +1,54 @@
// Regression fixture for #2766.
//
// A Go method with a POINTER receiver binds its receiver to the literal string
// `*Holder` (synthesizeGoReceiverBinding stores typeNode.text raw, deliberately,
// because method-owners.ts consumes the `*T` vs `T` distinction to model Go's
// value and pointer method sets). Before the decoration fallback in
// findClassBindingInScope, that string matched no class binding, so receiver
// typing declined at the BASE and every `h.field.Method()` here emitted no CALLS
// edge — the dominant Go idiom, silently missing from the graph.
//
// The value-receiver twin at the bottom is the control: it resolved before the
// fix and must keep resolving after it. Field decoration is NOT the variable —
// Go already normalizes field type bindings at capture via normalizeGoTypeName.
package handlers
import "fixture/repository"
type Holder struct {
thing repository.Thing
impl *repository.Impl
cart *repository.CartRepo
}
// Pointer receiver, interface-typed cross-package field.
func (h *Holder) RunInterface() error {
return h.thing.DoWork()
}
// Pointer receiver, concrete-typed cross-package field.
func (h *Holder) RunConcrete() error {
return h.impl.DoWork()
}
// Pointer receiver, concrete-typed cross-package field returning a value.
func (h *Holder) RunCart(tx int) *repository.CartRepo {
return h.cart.WithTx(tx)
}
// Control: a local variable receiver typed in the same function resolved even
// before the fix, via the text cascade rather than the decorated base.
func (h *Holder) RunLocal() error {
local := &repository.Impl{}
return local.DoWork()
}
type ValueHolder struct {
impl *repository.Impl
}
// Control: VALUE receiver. Binds as `ValueHolder` with no decoration, so this
// resolved before the fix and must not change.
func (v ValueHolder) RunFromValueReceiver() error {
return v.impl.DoWork()
}

View file

@ -0,0 +1,17 @@
package repository
// Thing is an interface-typed dependency, the shape a DI-wired Go service
// stores in a struct field.
type Thing interface {
DoWork() error
}
// Impl is the concrete implementation behind Thing.
type Impl struct{}
func (i *Impl) DoWork() error { return nil }
// CartRepo is a concrete-typed dependency reached through a struct field.
type CartRepo struct{}
func (c *CartRepo) WithTx(tx int) *CartRepo { return c }

View file

@ -1655,3 +1655,46 @@ describe('Go Child embeds Parent — inherited method resolution (SM-9)', () =>
expect(parentMethodCall!.source).toBe('Run');
});
});
// ---------------------------------------------------------------------------
// #2766: pointer-receiver base resolution
// ---------------------------------------------------------------------------
describe('Go pointer-receiver field chains (#2766)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'go-pointer-receiver-field-chain'),
() => {},
);
}, 60000);
const calls = (): Set<string> => edgeSet(getRelationships(result, 'CALLS'));
// The three rows that emitted nothing before the decoration fallback. All
// three have a POINTER receiver, which bound as the literal `*Holder` and
// matched no class, so receiver typing declined at the base.
it('resolves an interface-typed cross-package field through a pointer receiver', () => {
expect(calls()).toContain('RunInterface → DoWork');
});
it('resolves a concrete-typed cross-package field through a pointer receiver', () => {
expect(calls()).toContain('RunConcrete → DoWork');
});
it('resolves a concrete cross-package field returning a value', () => {
expect(calls()).toContain('RunCart → WithTx');
});
// Controls: these resolved BEFORE the fix. R11 requires they still resolve to
// the same target, so a regression here means the fallback moved an edge
// rather than adding one.
it('keeps resolving a local-variable receiver', () => {
expect(calls()).toContain('RunLocal → DoWork');
});
it('keeps resolving a value receiver', () => {
expect(calls()).toContain('RunFromValueReceiver → DoWork');
});
});