Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions __tests__/resolution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
20 changes: 20 additions & 0 deletions src/resolution/name-matcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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, '/'));
Expand Down