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

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions