
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;
}
}
| Feature | In the graph? |
|---|---|
| Classes, an interface, a record | yes, one node each |
Both halves of partial class Report | yes, a node per file |
| Methods, the constructor, a local function, a generic method | yes |
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 : IShape | no edge |
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:
| MediatR | Polly | |
|---|---|---|
| C# files | 43 | 415 |
| Type nodes / function nodes | 53 / 145 | 516 / 1,836 |
| Call sites seen | 600 | 5,600 |
| Resolved | 15.8% | 27.9% |
| Dropped as unknown | 494 | 3,800 |
imports edges | 0 | 0 |
| Build time (laptop) | 0.10 s | 0.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:
- 569
nameof(...)expressions, which aren't calls at all - 557
newexpressions that construct one of Polly's own types, out of 1,217 object creations. The most common isnew 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
typeas well asfunction, as Java's entry does, and resolve anewto the class, as CGraph has done for Java since CGraph#66. nameof: skip an invocation whose callee is the bare identifiernameof, 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: markinterface_declarationininterface_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.