Skip to content

C#: method calls on a typed receiver (field/param/local.Method()) are not emitted as calls edges #1609

Description

@JensD-git

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)

  1. 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.
  2. 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)

  1. In the C# invocation branch, capture the simple-identifier receiver into member_receiver.
  2. Build a per-method symbol table: types of fields/properties (incl. base classes), parameters, and locals (Type v, var v = new Type(), (Type)expr).
  3. 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#

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions