NxtSoftLabs
← All writing

C# code graphs: what CGraph extracts, and what it misses

October 1, 2026·7 min read

An ink-drawn C# class graph on paper: most methods are joined by solid lines, while a property, a constructor call, and an interface link are drawn as faint dashed outlines where no edge exists.

CGraph extracts a usable C# code graph today: classes, interfaces, records, both halves of a partial class, methods, constructors, and the calls between them, including extension methods and calls across files. It also misses three things a C# developer will notice: calls made inside properties, new expressions, and the link from an interface to the classes that implement it. We measured both halves on a small fixture with one case per feature, and on two real libraries, MediatR and Polly.

C# has shown up in CGraph's language list for a while, but unlike Rust, Kotlin, and Java, it never had a post of its own. This is that post, and it's mostly an honest inventory.

How the C# extractor is built

C# runs on CGraph's configured extractor: a table that tells the shared tree-sitter walker which node types matter. The whole C# entry is small. Types come from class_declaration, interface_declaration, struct_declaration, enum_declaration, record_declaration, and namespace_declaration. Functions come from method_declaration, constructor_declaration, and local_function_statement. Calls come from invocation_expression and object_creation_expression, with the callee read from the function field, and obj.Method() recorded by its member name.

What it doesn't set matters as much. Java's entry, built on the same table, reads callees from both name and type (its comment notes that type is where new Foo() keeps the callee), marks interface_declaration as a contract with interface_node_types, and adds a member handler and a callee-name resolver. C#'s entry sets none of these.

Ten features, one fixture

To see exactly what that table produces, we wrote a three-file fixture with one case per feature, built it with CGraph bin-v0.7.2, and read the graph back:

public interface IShape { double Area(); }

public sealed class Circle : IShape
{
    public Circle(double r) { _r = r; }
    public double Area() => Math.PI * _r * _r;
    public double Diameter => Twice(_r);        // expression-bodied property
    private static double Twice(double x) => x * 2;
}

public partial class Report                     // the other half is in Report.Footer.cs
{
    public string Render(IShape shape)
    {
        var c = new Circle(1);
        string Local() => shape.Describe();      // extension method, from a local function
        Validate(nameof(shape));
        return Util.Echo(Local()) + Footer() + c.Diameter;
    }
}
FeatureIn the graph?
Classes, an interface, a recordyes, one node each
Both halves of partial class Reportyes, a node per file
Methods, the constructor, a local function, a generic methodyes
Call to a method in the other partial file (Footer, Validate)yes, resolved
Extension method call (shape.Describe())yes, resolved to Describe
Static call (Util.Echo(...))yes
Interface call (s.Area())yes, but by name: it fans out to all three Area methods
A property (Diameter)no node
The call inside that property (Twice(_r))no edge: Twice has no callers at all
new Circle(1)no edge to the constructor
Circle : IShapeno edge

Diagram of the fixture's graph: Render calls Footer, Validate, Echo, and Local; Local calls Describe; Describe calls Area. Three dashed links show what is missing: the Diameter property calling Twice, Render constructing Circle, and Circle implementing IShape.

Why each gap exists

new has no callee. In the tree-sitter C# grammar CGraph pins, invocation_expression keeps its callee in a field named function, but object_creation_expression keeps the constructed type in a field named type. The C# config reads callees only from function, so a new expression is counted as a call and then dropped, because there's no name to resolve. The constructor node exists; nothing points at it.

Properties aren't functions. property_declaration isn't in the list of function node types. A property with a body is code that runs and calls things, but with no node to own those calls, they vanish. In the fixture, Twice is only ever called from Diameter, so impact on Twice reports nothing depends on it.

No interface marker means no implements. CGraph infers implements structurally: a type satisfies an interface when the interface's method names are a subset of its own. That rule only sees interfaces the config marks as contracts through interface_node_types, and C#'s config doesn't set it. Without Circle → IShape, an interface call can only be resolved by name. Here that happens to work: s.Area() reaches all three Area methods. In a larger codebase the same rule links an interface call to every method of that name, whether or not its class implements the interface.

There's a fourth effect that isn't a gap in the graph, but it distorts the numbers: nameof(shape) is parsed as an invocation. It's counted as a call and dropped as unknown, though nothing is called at runtime. Removing it from the fixture took the unknown drops from 4 to 3, and removing new Circle(1) did the same.

On real code

We built graphs for src/ of MediatR (commit 916ef1b) and Polly (commit 0275bc2) with the same binary:

MediatRPolly
C# files43415
Type nodes / function nodes53 / 145516 / 1,836
Call sites seen6005,600
Resolved15.8%27.9%
Dropped as unknown4943,800
imports edges00
Build time (laptop)0.10 s0.48 s

Extraction is fast and the structure is there: Polly's 415 files yield 516 type nodes and 1,836 function nodes. Resolution is where C# falls short. An "unknown" drop means nothing in the graph bears the callee's name. Many of those are legitimate: LINQ and the rest of the .NET libraries aren't in the repository, so a call to Select or Sum should drop, and in the fixture both did. But in Polly's source, text counts put two kinds of non-legitimate drops at about 30% of the total:

Stacked bar chart of Polly's 3,800 unknown call drops: about 569 are nameof expressions, about 557 are new expressions constructing Polly's own types, and the remaining 2,674 are mostly calls into .NET libraries and other unresolved names.

  • 569 nameof(...) expressions, which aren't calls at all
  • 557 new expressions that construct one of Polly's own types, out of 1,217 object creations. The most common is new ResiliencePipelineBuilder, 122 times. Each should link to the type it constructs, and today none does.

These are text counts over src/, not graph queries, so treat them as close estimates. The fixture shows each kind drops one call site per occurrence.

Two more structural facts show up in the table. using directives produce no imports edges, because a C# using names a namespace, not a file, so there's no file-level import graph to walk. And properties have no nodes: Polly's source has 320 { get accessors alone, so whatever a property body calls has a caller missing.

What it would take

None of these needs a new extractor. Each is a change to the C# entry or its handlers:

  • Constructor calls: read callees from type as well as function, as Java's entry does, and resolve a new to the class, as CGraph has done for Java since CGraph#66.
  • nameof: skip an invocation whose callee is the bare identifier nameof, so it isn't counted as a call.
  • Properties: treat a property's accessor bodies (and an expression-bodied property's value) as a function owned by the property, so the calls inside have a caller.
  • implements: mark interface_declaration in interface_node_types, the switch Java's entry uses, so the structural rule and interface dispatch apply to C# instead of name fan-out.

Until those land, C# graphs from CGraph are good for structure: what types exist, what methods they hold, and most direct method-to-method calls. They're weaker for impact analysis in code that does its work in properties or constructs its own types. If that's your codebase, run impact knowing a dependent reached only through a property or a new won't show up. The source and releases are on GitHub.