feat(graph): add C# tree-sitter support to the code graph - #307
feat(graph): add C# tree-sitter support to the code graph#307InfiniLakeSoftware wants to merge 2 commits into
Conversation
Graft's structural graph was blind to every .cs file, so nothing in a .NET
codebase got node or edge coverage — no callers, no blast radius, no
signature extraction, and coupling-based ranking had nothing to rank C#
symbols by.
Adds tree-sitter-c-sharp as a grammar and wires "csharp" through the
Language union, extension mapping, and grammar table. describeCSharp()
recognizes the type declarations (class / struct / interface / record /
enum / delegate) and the method-shaped members (method, constructor,
destructor). Explicit interface implementations take an
interface-qualified idName (`void IFoo.Bar() {}` -> `#Widget.IFoo.Bar`),
since the bare name can legally collide with a public member of the same
name in the same class — the same disambiguation Go's receiver-qualified
methods use.
Visibility is keyword-based rather than name-based: unlike Go, C#
accessibility is an explicit modifier and unmarked members default to
private/internal, so "no modifier" reads as not exported.
Heritage edges come from base_list. Because the grammar doesn't
syntactically distinguish a base class from an implemented interface,
every entry is emitted as "extends"; resolve.ts's "extends" already
matches class-or-interface targets, so an interface base still resolves.
bindings.ts gains a C# arm so member calls resolve through the receiver's
bound type: field, property, parameter, and local variable declarations
all contribute type bindings, and a bare `Foo()` is treated as an
implicit-this member call, since C# has no free-standing functions.
Also fixes a call-detection bug this surfaced: walk() hardcoded
`call_expression` as the call-node type for every non-Python language, but
C#'s grammar uses `invocation_expression`. Without that, zero C# call
edges would ever have been extracted.
Adds test/graph-csharp.test.ts (modelled on test/graph-go.test.ts):
structural node coverage plus heritage and call-edge resolution, including
the explicit-interface-implementation id collision case.
Files changed:
- package.json
- package-lock.json
- src/graph/extract.ts
- src/graph/bindings.ts
- src/graph/types.ts
- test/graph-csharp.test.ts
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…roperty/local-function nodes
Corrects three simplifications left behind by the initial C# support, all
of which direct parse-tree inspection showed were cheaper to fix than the
comments there claimed.
Record vs record-struct: `record`, `record class`, and `record struct` all
produce a `record_declaration`, but the distinguishing keyword rides along
as an anonymous child token that the named-child and field reads never
saw. csRecordKind() scans node.children for an unnamed `struct` child, so
only `record struct` lands on kind "struct" — bare `record` and
`record class` stay "class".
extends vs implements: resolveName() now returns the matched node's own
kind alongside id and confidence, and a heritage edge whose target
resolves to an interface is relabelled "implements". Gated to .cs files on
purpose, matching the existing endsWith(".go") precedent in the same
function: TS and Python already emit the correct relation at extraction
time, and TS declaration merging (`interface Foo` alongside `class Foo` in
one file) can make a genuine `extends` resolve to the interface half, so
an ungated flip would be regression risk with no upside. An unresolved
base — always an external type — still falls back to "extends".
Property and local-function nodes: property_declaration gets a new Kind
value "property"; local_function_statement reuses "function" and nests
under its declaring method (`File.cs#Class.Method.Local`). Properties
expose no `body` field, so csPropertyBody() finds the accessor_list or
arrow_expression_clause for the header span, and they take the same
explicit-interface-qualified idName as methods, since `int IFoo.Count
{ get; }` is legal and can collide. Local functions needed no special
walk — the existing recursion through a method's block already reaches
them. bindings.ts's defName() was extended in step, since its scope stack
is documented as needing to stay in lockstep with extract.ts's.
Calls to a local function deliberately still produce no edge: C# bare
calls route through the member-call path, which matches only kind
"method", and widening the fallback kinds would let scope-blind resolution
wire a call to a sibling method's block-scoped local function. Correct
handling needs a scope-prefix check in resolve.ts. Documented at the
calleeName C# branch and in the test; left as a separate follow-up.
Also refreshes three comments the change made stale (the CS_TYPE_KINDS
record note, the heritageEdges C# note, and calleeName's "always-empty
function kind" note) and folds indexers into the existing operator
exclusion comment, since the parse confirmed indexers carry no `name`
field either.
Verified on 112 real .cs files from a .NET codebase: 173 property nodes,
`record struct Token` as struct while `record` and `sealed record` stayed
class, one `implements` edge with all 22 class bases still `extends`
(including an unresolved external base), and a local function correctly
nested and spanned.
Files changed:
- src/graph/extract.ts
- src/graph/resolve.ts
- src/graph/bindings.ts
- src/graph/types.ts
- test/graph-csharp.test.ts
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
🌱 graft blast radius1 area changed → 3 areas can be affected. 4 dependent symbols, depth 2. flowchart TB
A0(("Graph Construction<br/>2 symbols"))
A1(("Graph Engine<br/>1 symbol"))
A2(("Pull Request Review<br/>1 symbol"))
classDef reached fill:#D9EDF3,stroke:#3AA7C9,stroke-width:1.5px,color:#0E313C;
class A0,A1,A2 reached;
Who knows this code — 2 people across 4 areas
Ownership is git history over each area's own files, weighted towards recent work (120-day half-life). Merge commits and bots are dropped, and you are dropped from your own PR. A name with no All 4 dependent symbols, grouped by areaGraph Construction — 2 symbols in 2 files
Graph Engine — 1 symbol in 1 file
Pull Request Review — 1 symbol in 1 file
Test signal per changed area — 1 ⚠Reached = a node under a test path has a resolved edge into the changed symbol. It undercounts anything called indirectly — through a CLI, a spawned process or a dynamic import — so read a low ratio as “look here”, never as a coverage gate.
35 test suites also reference this code38 symbols, kept out of the diagram and the table so they cannot crowd out the areas a reviewer has to look at.
Open the interactive graph → — click an area to see its dependent symbols at file:line. |
Problem
Graft's structural graph is blind to
.csfiles, so a .NET codebase gets no node or edge coverage at all — no callers, no blast radius, no signature extraction, and coupling-based ranking has nothing to rank C# symbols by. On a real ~3,400-file .NET repo,graft/.graph/wiring.jsonhad zero entries for any C# symbol.This adds
csharpas a first-class extractor alongside the existing TS/Python/Go/Java/Kotlin/Swift/PHP/R support.Two commits: the extractor itself, then a follow-up sharpening record/struct kinds, extends-vs-implements, and property/local-function nodes.
What it covers
recordandrecord classmap toclass;record structmaps tostruct. All three produce onerecord_declaration, with the distinguishing keyword riding along as an anonymous child token that the named-child and field reads never see, socsRecordKind()scansnode.childrenfor an unnamedstructchild.property_declaration(newpropertykind) andlocal_function_statement(nested under its declaring method).void IFoo.Bar() {}→#Widget.IFoo.Bar), since the bare name can legally collide with a public member of the same name on the same type — the same disambiguation Go's receiver-qualified methods already use.base_list. The grammar cannot syntactically distinguish a base class from an implemented interface, so extraction emits every entry asextendsand the resolver relabels toimplementswhen the resolved target's own kind isinterface. That relabel is gated to.cson purpose: TS declaration merging (interface Fooalongsideclass Foo) can legitimately resolve a realextendsto the interface half. An unresolved base — always an external type — staysextends.Foo()is treated as an implicit-thismember call, since C# has no free-standing functions.A bug this surfaced
walk()hardcodedcall_expressionas the call-node type for every non-Python language, but C#'s grammar usesinvocation_expression. Without that fix, zero C# call edges would ever be extracted. Fixed in the first commit.Known limitation, deliberately left
Calls to a local function produce no call edge. C# bare calls route through the member-call path, which matches only kind
method, and widening the fallback kinds would let scope-blind resolution wire a call to a sibling method's block-scoped local function:Resolving these correctly needs a scope-prefix check in
resolve.ts— a real design change rather than a widened kind list. Per graft's own stated philosophy of never wiring an edge to the wrong symbol, a missing edge is the correct interim outcome. This is documented at thecalleeNameC# branch and in the test.Note on
resolve.tsThe second commit extends
resolveNameto return the matched node's ownkindalongside its id, because the heritage relabel needs it to tell an interface base from a class one. That is additive — existing callers ignore the extra field.Testing
test/graph-csharp.test.ts(new, modelled ontest/graph-go.test.ts): structural node coverage; heritage and call-edge resolution; the explicit-interface-implementation id collision case;record structvsrecord/record classkinds; an interface base producingimplementswith an assertion that noextendsedge survives alongside it; an unresolved external base still emittingextends; and property/local-function nodes asserted on kind, name, signature, span and containment..csfiles: 29,022 C# nodes extracted (3,193 class, 17,136 method, 4,737 property, 340 interface, 91 enum, 10 struct, 80 function, 9 type). Spot-checkedrecord structlanding asstructwhilerecord/sealed recordstayedclass, oneimplementsedge with all class bases stillextendsincluding an unresolved external base, and a local function correctly nested and spanned.