Skip to content

fix(resolution): don't bind a delegating call to its own enclosing method - #1939

Open
danusha2345 wants to merge 1 commit into
colbymchenry:mainfrom
danusha2345:fix/method-match-self-loop
Open

danusha2345 wants to merge 1 commit into
colbymchenry:mainfrom
danusha2345:fix/method-match-self-loop

Conversation

@danusha2345

@danusha2345 danusha2345 commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Problem

matchMethodCall's strategy 3 guesses the target from the method name: first the single same-language method with that name, then the class whose name shares a word with the receiver. A delegating wrapper calls a method with its own name on another object, so the guess lands on the enclosing method itself, and the graph gets a self-loop.

  • request.getHeaders() inside AhcWSRequest.getHeaders() (Play): "request" overlaps "AhcWSRequest".
  • cache.getIfPresent(k) inside NamedCaffeineCache.getIfPresent(k): the only method with that name.

callers/impact then report the method as recursive, and the edge to the real target is missing.

Fix

In strategy 3 only (both branches), a candidate that is the calling method itself is rejected when the call has an explicit receiver naming another object, and the call stays unresolved.

Real recursion is kept:

Strategies 1 and 2 are unchanged, so ClassName.method() recursion is still resolved.

Measured

Twelve projects were indexed with main (ba3c21e) and with this branch: Kotlin ×3, C, Rust ×2, Go, Python ×2, PHP, C#, and playframework (Java + Scala). Node counts are identical, no edges were gained, and every lost edge is a calls edge whose source equals its target:

project lost self-loops examples
playframework 130 request.get/delete/head in AhcWSRequest, cache.*, underlying.*, delegate.*
Kotlin Android app (C/Java/Kotlin) 9 Log.d/i/w/e inside a Logx wrapper, socket.connect
Kotlin apps 8 / 7 / 2 peer.createOffer, serverSocket.close, _state.update
MediatR 3 _innerWriter.Write
Rust 2 / 0 handle.stop delegating to the inner handle
Flask 2 loader.list_templates
Go 1 gonet.ListenTCP; the real r.sendBatchAttempt recursion is kept
Slim 1 ref.setAccessible

I hand-checked all of them and found no real recursion. One trade-off: recursion through another instance of the same type (left.visit() inside Node.visit) is now unresolved when it reaches strategy 3. It didn't appear in any of the twelve projects, and no edge is better than a wrong one.

Tests

Four new cases in __tests__/resolution.test.ts:

  • Java covers both strategy-3 branches, and Scala and TypeScript cover delegating wrappers. All three fail on main.
  • A recursion case (Java this.depth(), bare size(), Go named receiver) passes before and after. It fails if the Go exemption is removed.

The related suites pass (898/898). The full suite has no failures from this change.

🤖 Generated with Claude Code

@danusha2345

Copy link
Copy Markdown
Contributor Author

Rebased onto main. The Java and TS self-loop cases now pass on main (#2166, #2169), so they're dropped. Still failing on main and covered here: Scala wrapped.unbind() inside unbind, a Scala receiver whose name shares a word with the class, and Swift inner.unbind(). The real-recursion guard test is kept.

🤖 Generated with Claude Code

@danusha2345
danusha2345 force-pushed the fix/method-match-self-loop branch from 723b53c to da6fc96 Compare October 1, 2026 15:44
@danusha2345

Copy link
Copy Markdown
Contributor Author

Rebased onto main (34ede4d). Two more cases now pass on main and are dropped together with their tests: the Scala receiver that shares a word with the enclosing class (#2221 — the word-overlap branch now refuses the caller itself, so this PR's check there is removed), and Swift's inner.unbind() (its let inner: Mapping property is now typed, #2205). What remains is the single-candidate branch: Scala's wrapped.unbind(value) inside WrappedMapping.unbind, where the enclosing method is the only unbind in the project, still resolves to itself on main — #2223's veto doesn't reach it, because that call is recorded as wrapped.unbind, not by its bare name. The real-recursion guard (this.depth(), bare size(), Go's r.sendBatchAttempt()) is kept.

🤖 Generated with Claude Code

@danusha2345
danusha2345 force-pushed the fix/method-match-self-loop branch from da6fc96 to 5094435 Compare October 6, 2026 08:21
@danusha2345

Copy link
Copy Markdown
Contributor Author

Rebased onto main (6421644).

One more piece is covered by main now and is dropped: the Go exemption in isForeignReceiverSelfLoop. It kept r.m() inside func (r *T) m() from being treated as a delegating call when the receiver's type was not inferred (an unexported, generic or function-typed receiver went to strategy 3 at confidence 0.7). Since #2361 a Go method receiver is resolved on its declared type before strategy 3 is reached: on main every Go recursion shape I tried (exported, unexported, generic, value receiver, a receiver list split over lines, a function type, calls under go / defer / a closure) resolves at 0.9 and never gets to this branch. The helper is now a one-line check, and it no longer reads the source file.

The Go case stays in the recursion test as a control; it passes on main and here.

What remains is unchanged: in strategy 3's single-candidate branch, a call through an explicit receiver that is not this / self / Self / super / cls is not bound to the method it is written in. Scala's wrapped.unbind(value) inside WrappedMapping.unbind still resolves to itself on main.

Checked that no real recursion is lost against main: this. / self. / Self:: / $this-> / self:: / static:: / [self m] / self:m() recursion in 17 languages gives the same self-edges on main and on this branch.

…thod

matchMethodCall's name-only Strategy 3 single-candidate branch matched
`other.m()` written inside `m()` back to `m` itself whenever `m` is the
only method of that name: Scala's `wrapped.unbind(v)` inside
WrappedMapping.unbind. An explicit receiver that names another object
can't be the enclosing method's own instance, so that self-loop is the one
guess known to be wrong — return no edge. (The receiver/class word-overlap
branch already refuses the caller itself, colbymchenry#2221.)

Real recursion is kept: `this.`/`self.`/`super.`/`cls.`/`Self::`
receivers and bare calls (never reach Strategy 3). Go's named method
receiver (`func (r *T) m()` calling `r.m()`) is resolved on its declared
type before Strategy 3 (colbymchenry#2361), so it needs no exemption here; the test
still pins it. Strategies 1/2 (explicit class-name receiver) are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@danusha2345
danusha2345 force-pushed the fix/method-match-self-loop branch from 5094435 to ac21e4b Compare October 7, 2026 11:56

This branch has not been deployed

No deployments
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.

1 participant