Skip to content

C#: a call through an interface dead-ends at the interface method, so injected dependencies cut every chain #3003

Description

@durmazoguzhan

A C# call through an interface lands on the interface's method node and stops there. The implementation is a separate node with nothing joining the two, so any chain that passes through a constructor-injected dependency is cut at that point.

Three files:

// IReport.cs
public interface IReport { void Build(); }

// Report.cs
public class Report : IReport { public void Build() { Format(); } public void Format() { } }

// Runner.cs
public class Runner {
    private readonly IReport _report;
    public Runner(IReport report) { _report = report; }
    public void Go() { _report.Build(); }
}

Graph (graphify 0.9.48, edges other than contains):

IReport   -> .Build()   [method]
Report    -> IReport    [implements]
Report    -> .Build()   [method]
Report    -> .Format()  [method]
.Build()  -> .Format()  [calls]        <- source: report_report_build
.Go()     -> .Build()   [calls]        <- target: ireport_ireport_build

Two .Build() nodes, ireport_ireport_build and report_report_build, and no edge between them. Go calls the interface one, Format hangs off the implementation one, so a directed walk from .Go() never reaches .Format(). The receiver typing is doing its job here: _report is correctly typed as IReport, and the interface really is what the call site names.

How much it costs on a real codebase

A .NET 8 service, 1457 C# files, constructor injection throughout (Scrutor assembly scanning, so every dependency is an interface):

  • 1511 calls edges land on a method owned by an interface and dead-end there
  • 115 interface types have at least one implementer
  • 107 of those 115 have exactly one implementer

So on this corpus the ambiguous case is 8 types out of 115. The rest have one possible target each.

graphify path between two ends of such a chain returns "No directed path found", and affected stops at the interface, which makes a blast radius look far smaller than it is.

What other languages already do here

Pascal has the closest analogue, and its docstring states the principle I want to borrow:

walking inherits is a structurally justified resolution, not a heuristic guess; guessing by name across an entire multi-thousand-file corpus is not the same bet.

resolve_pascal_inherited_calls walks the caller's inherits chain across files and emits an edge only when exactly one class at the nearest matching level owns a same-named method. resolve_ruby_member_calls uses the same single-owner guard. Interface dispatch in C# is the same kind of bet in the other direction: instead of walking up to find where a method is declared, walk down from a declaration to the one type that implements it.

Proposed shape

A csharp_interface_dispatch resolver in the tail registry that adds a member-level edge from an interface method to the implementing method:

ireport_ireport_build  --dispatches_to-->  report_report_build

Guards, matching the existing resolvers:

  • the interface has exactly one implementer (implements edges into it)
  • that implementer owns exactly one method of the same name
  • zero or several candidates at either step leaves the pair alone

Confidence INFERRED, not EXTRACTED. The target is structurally forced when there is one implementer, but the source text does not name it, and that is what the existing tiers mean.

On the corpus above this emits 862 edges, +2.8% on 30852, and skips nothing as ambiguous. It reconnects the 1511 dead-ending call edges through 862 links rather than 1511, because the join is per method rather than per call site.

Alternatives I ruled out

  • Retarget the existing call from the interface method to the implementation. Loses the fact that the call site names the interface, which is true and worth keeping.
  • Add caller -> implementation calls edges. 1511 new edges on that corpus, and it inflates every caller's call list with a target the source never mentions.
  • Reuse implements at member level. Its type-level direction is implementation to interface, and a directed walk needs the opposite direction to be useful, so overloading the name would make traversal read backwards.

Open question: dispatches_to is my guess at a name that fits the existing vocabulary. Happy to use whatever you prefer.

Related

I have a patch with tests and will open a PR against v8.

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