csharp: stop member calls from binding to the calling method itself - #516
Merged
Conversation
A member call through an untyped receiver (a ctor-injected field, the dominant DI pattern) could bind to the enclosing class's own same-named method: the caller-receiver fallback asks only whether the caller's class declares the name, so a facade wrapping a same-named repository method became a 0.9 self-loop with no Origin stamp. DefaultOriginFor then backfills conf>=0.9 to ast_resolved, so the tstypes applier computed the correct target and refused to claim the site. - resolveMethodCall: exempt member_call edges from the caller-receiver fallback (mirrors the PHP shield above it) and never locality-bind a member call to its own caller. - tstypes claimable: an origin-unstamped member_call bind without a resolution/semantic_source marker is name evidence regardless of its backfilled confidence — claim it, so enrichment heals existing stores without a reindex.
…repo exact-type origins Review found the facade gate orphaned one legitimate population: recursion through a field typed as the enclosing class (next.Print() inside Print). The resolver now leaves it a stub by design, but applyCall's self-guard also refused it, so the site went permanently unresolved. Split upgradeOrCreateCall so the self-target path claims an existing stub without ever minting a fresh self-edge. Also: cross_repo's exact-type tiers now stamp OriginASTResolved like the main resolver's typed passes — unstamped, their receiver-typed binds were indistinguishable from the name-tier family claimable() may reclaim. Comment accuracy fixes and a non-vacuity assert in the facade test.
zzet
approved these changes
Aug 9, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Title: csharp: stop member calls from binding to the calling method itself
Problem
Field-testing on my production C# codebase:
callerson a repositoryinterface method never returns the service that calls it through a
ctor-injected field — the dominant DI/delegation pattern there. The graph
shows why: the service's call is bound to the calling method itself as a
0.9-confidence self-loop, and no edge into the interface exists at all.
The trigger is the facade shape — a service wrapping a repository method
under the same name:
Sibling methods whose names differ from the wrapped method (e.g. an
Asyncsuffix) bind correctly at 0.95 via csharp-types — which is what hid this for
so long.
Root cause (two layers)
resolveMethodCallreaches the fallback with noreceiver_type(fieldsnever reach the extraction tenv), it asks only "does the caller's own
class declare this name?" — and for a facade the answer is yes, so the
call binds to the enclosing method at 0.9 with no Origin stamp. The PHP
shield directly above the fallback documents this exact self-bind failure
for
$this->handler->setFormatter(); C# had no equivalent.DefaultOriginFormapsconfidence >= 0.9toast_resolved, so the tstypes applier'sclaimable()ranks the guess at the AST ceiling. Enrichment then computesthe correct interface target, hits the same-line self edge in
upgradeOrCreateCall, and honours don't-double-the-call-site — the rightedge is silently dropped, permanently.
A
member_calledge that reaches the fallback untyped necessarily has anexplicit receiver that is not
this/base(those carryreceiver_typefrom extraction), so the caller's own member set is no evidence about it.
Fix
resolveMethodCall: exemptmember_calledges from the caller-receiverfallback, mirroring the PHP shield; and never let the locality fallback
bind a member call to its own caller (
x.Foo()insideFoois the facadeshape, not recursion — recursion is an unqualified call).
claimable: an origin-unstampedmember_callbind with noresolution/semantic_sourcemarker came from the name-locality tiers byconstruction — treat it as claimable regardless of the backfilled
confidence. This half also heals existing stores on their next
enrichment pass: already-resolved edges never re-enter the resolver, so
without it every deployed index keeps its self-loops until a full rebuild.
The
member_callmarker is stamped only by the C# extractor, so both gatesare C#-scoped by construction; no other language's fallback behavior
changes. No extractor changes, no version bump.
Behavior changes worth knowing (reviewed for blast radius)
type-filtered 0.9 bind. Wrapped targets heal to a true 0.95 via
csharp-types enrichment (fields are exactly what it types); in the window
before enrichment — or with the C# provider disabled — these sites sit at
text_matched/unresolved instead. Recursion through a self-typed field(
next.Print()insidePrint— linked lists, chains of responsibility)is covered: the applier's self-guard now claims the extracted stub when
typed-field evidence names the calling method itself, while minting fresh
self-edges stays forbidden.
to
ast_resolved(the conf>=0.9 rule); as explicittext_matchedit nowweighs honestly in path confidence, provenance weighting, and
min_tierfiltering until enrichment lands the real bind at 0.95.
SuppressRedundantTextMatchesand could seed interface-dispatch fan-outfrom wrong callers; as stamped
text_matchedthey suppress and fancorrectly.
indexing and heal at each file's next enrichment pass (the claimable
half), not at reuse time.
Tests
TDD, both layers watched red first:
TestCSharpFacade_MemberCallDoesNotStealToSelf— the facade fixturethrough the real extractor +
ResolveAll; red on the self-loop at0.9/no-origin, green with the target off-self and any unstamped bind below
0.9. Control:
this.Helper()still binds its own class at ≥0.9.TestCSharp_FacadeSelfLoopIsClaimable— reproduces the resolver damage onan extracted fixture (self-target, 0.9,
Origin=""), runsEnrich, andrequires the site retargeted to the interface method at AST provenance
with the self-loop gone. Guard rail:
TestCSharp_ExplicitASTResolvedBindStaysUnclaimedpins that an edgeexplicitly stamped
ast_resolvedis still never retargeted.TestCSharp_SelfTypedFieldRecursionClaimsStub— the population anadversarial review round caught the first cut orphaning: recursion
through a field typed as the enclosing class. The resolver gate leaves it
a stub by design, and the applier's self-guard used to refuse the claim
too, stranding the site.
upgradeOrCreateCallis split so theself-target path claims an existing stub without ever being allowed to
create; watched red before the split.
Same review round: cross_repo's exact-type tiers now stamp
OriginASTResolvedlike the main resolver's typed passes — unstamped,their receiver-typed binds were indistinguishable from the name-tier family
claimable()may reclaim.Suites: resolver/indexer failure name-sets byte-identical to clean main on
my Windows box (pre-existing platform failures only, worktree-verified);
tstypes and languages fully green.
Validation
Deployed on a rebuilt binary with a from-scratch index:
self-loop to the wrapped interface method at 0.95 (csharp-types) plus the
concrete impl at 0.85 — and a second, older pinned cell (the original
sighting of this steal: a controller's
_repository.Readgrabbed by itsown
Read) flipped green with it. No other pinned cell drifted.service's call binds the repository interface method at 0.95 with the
impl fan-out at 0.85; the self-loop and the spurious service-interface
mirror are gone. Re-running my acceptance probe closed the loop — a
single
callersquery on the interface method now walks the fullproduction chain, including a second interface-dispatch hop to the
consumer the original investigation started from.