Skip to content

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

Closed

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#

Activity

  1. safishamsi commented on Jul 2, 2026

    @safishamsi
    Member

    Fixed on v8 (eebc406). Added _resolve_csharp_member_calls mirroring the C++/Swift pattern: the C# invocation branch now captures the member_access_expression receiver and defers to a receiver-typed resolver instead of a bare name match. A per-file name -> Type table (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 to Server.Save and NOT Cache.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/this calls unregressed. 8 new tests, full suite green (2841). Ships next release — thanks for the thorough diagnosis and the prototype numbers.

  2. JensD-git commented on Jul 3, 2026

    @JensD-git
    ContributorAuthor

    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!

  3. TheFedaikin commented on Jul 3, 2026

    @TheFedaikin
    Contributor

    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!

  4. added a commit that references this issue on Jul 18, 2026
  5. added a commit that references this issue on Jul 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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