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_expression branch extracts only the callee method name for x.Method() and sets is_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_calls step. 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 (honoring override/inheritance) → calls edge.
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#
Summary
For C#, invocations of the form
receiver.Method(...)wherereceiveris a strongly-typed field, property, parameter, or local variable do not produce acallsedge to the receiver type's method — even though the receiver's type is already known to graphify (areferencesedge 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
Expected: two
callsedges —Repository.Commit -> Repository.Log(A) andRepository.Commit -> Server.Save(B).Actual: only (A) is emitted. (B) is dropped. A
referencesedgeRepository -> Serveris present, so the type is known.The same happens when the receiver is a method parameter or a local variable:
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 0callsedges (the receiver type was known via areferencesedge), and a service class with 35parameter.Method(...)sites → 0callsedges (20referencesedges to the receiver type present). Path/blast-radius/queryresults 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
callsedges 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)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._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)
member_receiver.Type v,var v = new Type(),(Type)expr)._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