diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index 14b5198f4..0008e9395 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -6311,4 +6311,48 @@ bracketed() expect(reachedFrom('consumer.ts', 'HiddenFace')).toBe(false); }, 30000); }); + + describe('Name-only method match never binds a delegating call to its own caller', () => { + const selfLoops = (g: CodeGraph, file: string): string[] => + g.getNodesInFile(file) + .filter((n) => n.kind === 'method' || n.kind === 'function') + .flatMap((n) => g.getOutgoingEdges(n.id) + .filter((e) => e.kind === 'calls' && e.target === n.id) + .map(() => n.name)); + + it('drops the self-loop for a Scala delegating wrapper', async () => { + fs.writeFileSync(path.join(tempDir, 'Form.scala'), ` +package play.api.data +class WrappedMapping(wrapped: Mapping) { + def unbind(value: String): String = wrapped.unbind(value) +} +`); + cg = await CodeGraph.init(tempDir, { index: true }); + cg.resolveReferences(); + expect(selfLoops(cg, 'Form.scala')).toEqual([]); + }, 30000); + + it('keeps real recursion through this./bare calls and a Go named receiver', async () => { + fs.writeFileSync(path.join(tempDir, 'Tree.java'), ` +public class Tree { + public int depth(int n) { return n == 0 ? 0 : this.depth(n - 1); } + public int size(int n) { return n == 0 ? 0 : size(n - 1); } +} +`); + fs.writeFileSync(path.join(tempDir, 'relay.go'), ` +package relay +type relayClient struct{} +func (r *relayClient) sendBatchAttempt(retry bool) error { + if retry { + return r.sendBatchAttempt(false) + } + return nil +} +`); + cg = await CodeGraph.init(tempDir, { index: true }); + cg.resolveReferences(); + expect(selfLoops(cg, 'Tree.java').sort()).toEqual(['depth', 'size']); + expect(selfLoops(cg, 'relay.go')).toEqual(['sendBatchAttempt']); + }, 30000); + }); }); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 36365398d..ca85393be 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -10725,6 +10725,7 @@ export function matchMethodCall( !sharesReceiverWord(objectOrClass!, targetMethods[0]!) && !(ref.language === 'objc' && objcReceiverReaches(objectOrClass!, targetMethods[0]!, context)) && !(ref.language === 'php' && phpReceiverReaches(objectOrClass!, targetMethods[0]!, context)))) { + if (isForeignReceiverSelfLoop(targetMethods[0]!, objectOrClass!, ref)) return null; return { original: ref, targetNodeId: targetMethods[0]!.id, @@ -10836,6 +10837,25 @@ function isOutOfRepoBinding(name: string, ref: UnresolvedRef, context: Resolutio return !!binding && context.isOutOfRepoImport?.(binding.source, ref.filePath, ref.language) === true; } +/** Receivers that name the current object (or its own class/base) — a call + * through one of these may genuinely recurse into the enclosing method. */ +const SELF_RECEIVERS = new Set(['this', 'self', 'Self', 'super', 'cls']); + +/** + * Strategy 3's single-candidate branch matches by method NAME alone, so a + * delegating wrapper whose method is the only one of that name — + * `wrapped.unbind(v)` inside `unbind(v)` — name-matches the method it is + * written in and the resolver emitted a self-loop. (The word-overlap branch + * already refuses the caller itself.) A receiver that is an explicit, other + * object is by construction not the enclosing method's own instance, so that + * candidate is the one guess we know is wrong: no edge beats a wrong edge. + * Real recursion (`this.foo()`, `self.foo()`, bare `foo()`) is kept; Go's + * named method receiver (`r.foo()`) is typed and never gets here. + */ +function isForeignReceiverSelfLoop(candidate: Node, receiver: string, ref: UnresolvedRef): boolean { + return candidate.id === ref.fromNodeId && !SELF_RECEIVERS.has(receiver); +} + /** The directory of a Go file, which is its package: Go keeps one package per directory. */ function goPackageDir(filePath: string): string { return path.posix.dirname(filePath.replace(/\\/g, '/'));