Repository navigation
C#: method calls on a typed receiver (field/param/local.Method()) are not emitted as calls edges #1609
Description
Summary
For C#, invocations of the form receiver.Method(...) where receiver is a strongly-typed field, property, parameter, or local variable do not produce a calls edge to the receiver type's method — even though the receiver's type is already known to graphify (a references edge to that type is emitted). Only unqualified / this. calls and static-import calls are captured.
The type information needed to resolve these calls is present; it just isn't linked to the invocations. Swift and Python already have member-call resolution (_resolve_swift_member_calls, _resolve_python_member_calls); C# has none.
Minimal reproduction
// File: Sample.cs
public class Server
{
public bool Save() => true;
}
public class Repository
{
private Server _server = new Server(); // typed field
public bool Commit()
{
Log(); // (A) unqualified -> edge IS emitted
return _server.Save(); // (B) member call -> edge is MISSING
}
private void Log() { }
}Expected: two calls edges — Repository.Commit -> Repository.Log (A) and Repository.Commit -> Server.Save (B).
Actual: only (A) is emitted. (B) is dropped. A references edge Repository -> Server is present, so the type is known.
The same happens when the receiver is a method parameter or a local variable:
public static bool CopyToServer(Server server) // parameter
{
return server.Save(); // edge MISSING
}
public bool Run()
{
Server s = new Server(); // local (also: var s = new Server();)
return s.Save(); // edge MISSING
}Why it matters
In codebases where classes delegate through typed member/parameter objects (wrappers, service layers, transpiled code), this is not an edge case — it is the majority of the real call graph. In one C# project we measured a single wrapper class with 119 distinct field.Method(...) delegation call sites resolving to 0 calls edges (the receiver type was known via a references edge), and a service class with 35 parameter.Method(...) sites → 0 calls edges (20 references edges to the receiver type present). Path/blast-radius/query results across those boundaries are effectively blind.
To confirm the information is sufficient and the fix is straightforward, we prototyped a ~200-line post-processing resolver (reads the source + the emitted graph, mirrors the suggested-fix algorithm, does not modify graphify). On one project (~1000 files) it recovered the two cases above as edges (0 → 114 and 0 → 39 respectively) and added ~26k calls edges graph-wide with 0 dangling / 0 mistagged edges (including receivers typed via inherited base-class fields); of the 114 wrapper edges, 107 were exact caller/target name matches (pure delegation). So the receiver-type information is present and resolvable — it simply isn't linked at extract time. Doing it in the extractor (with proper base-class/interface awareness) would be strictly better than a post-pass.
Likely cause (from reading extract.py)
- The C#
invocation_expressionbranch extracts only the callee method name forx.Method()and setsis_member_call = True, but does not capture the receiver identifier (member_receiver) the way the generic branch does. The receiver is discarded, so even receiver-based resolution cannot run. - There is no
_resolve_csharp_member_callsstep. The bare method name then fails to match (method labels carry./()), so the call is dropped.
Suggested fix (mirror the Swift/Python path)
- In the C# invocation branch, capture the simple-identifier receiver into
member_receiver. - Build a per-method symbol table: types of fields/properties (incl. base classes), parameters, and locals (
Type v,var v = new Type(),(Type)expr). - Add
_resolve_csharp_member_calls()that maps receiver → static type →Type.Method(honoringoverride/inheritance) →callsedge.
For genuinely dynamic receivers (e.g. dynamic/reflection-created objects), an assignment-tracking heuristic (field = new T() / (T)Factory(...)) can pin the runtime type; but the statically-typed cases above are the common and fully-resolvable majority.
Environment
- graphify 0.9.1 (behavior re-checked against 0.9.4 release notes; not addressed)
- Language: C#
Activity
safishamsi commentedon Jul 2, 2026
Fixed on
v8(eebc406). Added_resolve_csharp_member_callsmirroring the C++/Swift pattern: the C# invocation branch now captures themember_access_expressionreceiver and defers to a receiver-typed resolver instead of a bare name match. A per-filename -> Typetable (fields, properties, parameters, locals, incl.var v = new T()) types the receiver;this.M()binds to the enclosing class,Type.M()to the named type, and everything is gated by the single-definition god-node guard.Verified against your repro and more: the ambiguous
_server.Save()now resolves toServer.Saveand NOTCache.Save(it was producing that wrong edge before), field/param/local/this/Type.static/cross-file receivers all resolve, and an untypable (dynamic) receiver or a method absent on the type produces no edge. Unqualified/thiscalls unregressed. 8 new tests, full suite green (2841). Ships next release — thanks for the thorough diagnosis and the prototype numbers.feat(extract): C# receiver-typed member-call resolution (#1609)
JensD-git commentedon Jul 3, 2026
Thanks for the fast fix — and @TheFedaikin , #1620 looks like a substantially more complete take (cross-file type resolution + tiered receiver inference under a never-wrong-edge bar). Really nice.
The approach matches what we'd prototyped as a post-processor, so I'm confident it lands well on our side. Context on scale: our corpus is a large C# codebase — ~160k nodes across 8 large subgraphs spanning many projects — where typed-member delegation is the call graph rather than an edge case. I'd be glad to run the #1620 branch against the full corpus before it merges and report back real numbers (edges recovered, false positives, resolver-miss categories) — a stress test well beyond the minimal repro, especially for the cross-file / inherited-member and method-return-var tiers.
One related thing we hit with our own tooling: cross-file base classes were sometimes not emitted as inherits edges at all (e.g. a concrete Impl : Base where Base is in another file/project — the edge was simply absent, not just unresolved). It reads like #1620's cross-file type resolution ("including cross-file inheritance") may already fix this as a side effect — could you confirm whether resolving class X : Base now emits the inherits edge for a cross-file base? If it doesn't, I'll open a focused issue with a minimal repro + corpus numbers in the same format; if it does, even better.
Thanks again to you both!
TheFedaikin commentedon Jul 3, 2026
Thanks for the kind words, hopefully it can cover both of our cases!
@JensD-git after the PR lands you can create and issues and tag me - we have a lot of Unity projects that will benefit greatly with the
C#improvements to graphify, but many of them are a little bit one note, so maybe I can miss something - if you see and opportunity for improvements and can reproduce some of the smaller edge cases in your work - let me know!fix(csharp): resolve recv.Method() by receiver's declared type, not b…
bump: version to 0.17.0
feat(csharp): namespace-aware member-call resolution + shadow poisoni…
Squashed commit of the following:
feat(extract): C# receiver-typed member-call resolution (Graphify-Lab…
feat(csharp): namespace-aware member-call resolution + shadow poisoni…
merge: backport upstream C# receiver-typing fixes (Graphify-Labs#1609 G…