From 809d2eb63b3ffad2244cc28051daa21ceb6ec855 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Fri, 31 Jul 2026 12:12:34 +0000 Subject: [PATCH] fix(resolution): type Go pointer-receiver bases at the class lookup (#2766) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../bench/receiver-resolution/baseline.json | 2 +- gitnexus/bench/scope-capture/baselines.json | 5 +- .../ingestion/languages/go/scope-resolver.ts | 11 ++ .../contract/scope-resolver.ts | 33 ++++++ .../passes/compound-receiver.ts | 21 +++- .../passes/receiver-bound-calls.ts | 2 + .../scope-resolution/scope/walkers.ts | 100 ++++++++++++++++++ .../handlers/handler.go | 54 ++++++++++ .../repository/repo.go | 17 +++ .../test/integration/resolvers/go.test.ts | 43 ++++++++ 10 files changed, 283 insertions(+), 5 deletions(-) create mode 100644 gitnexus/test/fixtures/lang-resolution/go-pointer-receiver-field-chain/handlers/handler.go create mode 100644 gitnexus/test/fixtures/lang-resolution/go-pointer-receiver-field-chain/repository/repo.go diff --git a/gitnexus/bench/receiver-resolution/baseline.json b/gitnexus/bench/receiver-resolution/baseline.json index 536b30beb..1b06e8fba 100644 --- a/gitnexus/bench/receiver-resolution/baseline.json +++ b/gitnexus/bench/receiver-resolution/baseline.json @@ -73,7 +73,7 @@ "explicitTypeArgs": "N/A", "indexElement": "INVISIBLE-GAP", "fieldReceiverCall": "RESOLVES", - "decoratedReceiverBase": "VISIBLE-GAP", + "decoratedReceiverBase": "RESOLVES", "decoratedFieldType": "RESOLVES" }, "javascript": { diff --git a/gitnexus/bench/scope-capture/baselines.json b/gitnexus/bench/scope-capture/baselines.json index d0ef387ca..94dad1501 100644 --- a/gitnexus/bench/scope-capture/baselines.json +++ b/gitnexus/bench/scope-capture/baselines.json @@ -1,12 +1,13 @@ { "_comment": "Per-language baselines for bench/scope-capture/measure.mjs --check. fingerprint = order-independent sha256 over the lang-resolution/-* 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", diff --git a/gitnexus/src/core/ingestion/languages/go/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/go/scope-resolver.ts index e55595a63..67cd7be8e 100644 --- a/gitnexus/src/core/ingestion/languages/go/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/go/scope-resolver.ts @@ -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), diff --git a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts index 174190464..2f2e06e6d 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts @@ -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 — diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts index 6b77da5dd..0f8263b01 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts @@ -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 diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts index 038ad2437..01252e355 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts @@ -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. diff --git a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts index e250f88a5..fdce9e5d2 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts @@ -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(); + let currentId: ScopeId | null = startScope; + const visited = new Set(); + 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; } diff --git a/gitnexus/test/fixtures/lang-resolution/go-pointer-receiver-field-chain/handlers/handler.go b/gitnexus/test/fixtures/lang-resolution/go-pointer-receiver-field-chain/handlers/handler.go new file mode 100644 index 000000000..40ac92155 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-pointer-receiver-field-chain/handlers/handler.go @@ -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() +} diff --git a/gitnexus/test/fixtures/lang-resolution/go-pointer-receiver-field-chain/repository/repo.go b/gitnexus/test/fixtures/lang-resolution/go-pointer-receiver-field-chain/repository/repo.go new file mode 100644 index 000000000..f177d4c27 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/go-pointer-receiver-field-chain/repository/repo.go @@ -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 } diff --git a/gitnexus/test/integration/resolvers/go.test.ts b/gitnexus/test/integration/resolvers/go.test.ts index fe99476c3..743918d8a 100644 --- a/gitnexus/test/integration/resolvers/go.test.ts +++ b/gitnexus/test/integration/resolvers/go.test.ts @@ -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 => 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'); + }); +});