Skip to content

feat(graph): add C# tree-sitter support to the code graph - #307

Open
InfiniLakeSoftware wants to merge 2 commits into
trailhq:mainfrom
InfiniLakeSoftware:feat/csharp-tree-sitter-support
Open

feat(graph): add C# tree-sitter support to the code graph#307
InfiniLakeSoftware wants to merge 2 commits into
trailhq:mainfrom
InfiniLakeSoftware:feat/csharp-tree-sitter-support

Conversation

@InfiniLakeSoftware

Copy link
Copy Markdown
Contributor

Problem

Graft's structural graph is blind to .cs files, 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.json had zero entries for any C# symbol.

This adds csharp as 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

  • Type declarations — class, struct, interface, record, enum, delegate. record and record class map to class; record struct maps to struct. All three produce one record_declaration, with the distinguishing keyword riding along as an anonymous child token that the named-child and field reads never see, so csRecordKind() scans node.children for an unnamed struct child.
  • Method-shaped members — method, constructor, destructor, plus property_declaration (new property kind) and local_function_statement (nested under its declaring method).
  • Explicit interface implementations take an interface-qualified id (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.
  • Visibility is keyword-based, not 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 from base_list. The grammar cannot syntactically distinguish a base class from an implemented interface, so extraction emits every entry as extends and the resolver relabels to implements when the resolved target's own kind is interface. That relabel is gated to .cs on purpose: TS declaration merging (interface Foo alongside class Foo) can legitimately resolve a real extends to the interface half. An unresolved base — always an external type — stays extends.
  • Receiver-type bindings so member calls resolve through the receiver's bound type: field, property, parameter and local-variable declarations all contribute, and a bare Foo() is treated as an implicit-this member call, since C# has no free-standing functions.

A 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 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:

class A {
    void M1() { void Helper() {} Helper(); }
    void M2() { Helper(); }   // not legal C#, but graft can't tell
}

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 the calleeName C# branch and in the test.

Note on resolve.ts

The second commit extends resolveName to return the matched node's own kind alongside 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 on test/graph-go.test.ts): structural node coverage; heritage and call-edge resolution; the explicit-interface-implementation id collision case; record struct vs record/record class kinds; an interface base producing implements with an assertion that no extends edge survives alongside it; an unresolved external base still emitting extends; and property/local-function nodes asserted on kind, name, signature, span and containment.
  • Full suite green (1,219 passing, 5 skipped).
  • Verified against a real .NET codebase of ~3,400 .cs files: 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-checked record struct landing as struct while record/sealed record stayed class, one implements edge with all class bases still extends including an unresolved external base, and a local function correctly nested and spanned.

InfiniLakeSoftware and others added 2 commits September 8, 2026 10:13
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>
@trailhq-graft

trailhq-graft Bot commented Sep 8, 2026

Copy link
Copy Markdown

🌱 graft blast radius

1 area changed → 3 areas can be affected. 4 dependent symbols, depth 2.
Tests: Graph Analysis has tests the diff did not touch.
Tag: @anirudhkumar-nanonets — 4 of 4 areas · @shhdwi — Graph Analysis, Graph Construction

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;
Loading
Can be affected Symbols Nearest hop Reached from
Graph Construction 2 src/graph/build.ts:L151-L410 buildGraph — calls, depth 1 Graph Analysis
Graph Engine 1 src/engine.ts:L91-L101 graph — calls, depth 2 Graph Analysis
Pull Request Review 1 src/app/review.ts:L45-L99 reviewPullRequest — calls, depth 2 Graph Analysis
Who knows this code — 2 people across 4 areas
Area Who knows it
Graph Analysis · changed @shhdwi — 15 commits, last 25d ago · @anirudhkumar-nanonets — 12 commits, last 14d ago
Graph Construction · affected @anirudhkumar-nanonets — 18 commits, last 19d ago · @shhdwi — 5 commits, last 27d ago
Graph Engine · affected @anirudhkumar-nanonets — 16 commits, last 1mo ago
Pull Request Review · affected @anirudhkumar-nanonets — 3 commits, last 7d ago

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 @ has no GitHub handle in its commit email — tag them by hand, or add a .mailmap entry. A suggestion from history, not a CODEOWNERS rule.

All 4 dependent symbols, grouped by area

Graph Construction — 2 symbols in 2 files

  • src/graph/build.ts:L151-L410 — buildGraph (calls, depth 1)
    291: const edges = resolveEdges(nodes, rawEdges, { goModules: readGoModules(root, repoFiles) });
  • src/graph/refresh.ts:L150-L227 — ensureFreshGraph (calls, depth 2)

Graph Engine — 1 symbol in 1 file

  • src/engine.ts:L91-L101 — graph (calls, depth 2)

Pull Request Review — 1 symbol in 1 file

  • src/app/review.ts:L45-L99 — reviewPullRequest (calls, depth 2)
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.

  • Graph Analysis — 1 of 21 reached · 3 test files reach it, none changed here
    • not reached: csExplicitInterfaceOf, csTypeNameOf, defName, isClassNode, visit, handleCSharp, walk, describe, …12 more
35 test suites also reference this code

38 symbols, kept out of the diagram and the table so they cannot crowd out the areas a reviewer has to look at.

  • test/ask-index.test.ts
  • test/ask.test.ts
  • test/container-extract.test.ts
  • test/context-only-dir.test.ts
  • test/context.test.ts
  • test/covers.test.ts
  • test/generic-extract.test.ts
  • test/graph-cross-language.test.ts
  • test/graph-go.test.ts
  • test/graph-incremental.test.ts
  • test/graph-invariants.test.ts
  • test/graph-java.test.ts
  • test/graph-languages.test.ts
  • test/graph-php.test.ts
  • test/graph-posix-paths.test.ts
  • test/graph-python.test.ts
  • test/graph-r-classes.test.ts
  • test/graph-r-phase3.test.ts
  • test/graph-r-phase4.test.ts
  • test/graph-r-phase5.test.ts
  • …15 more

⚠️ 2 changed files not in the graph (package-lock.json, package.json) — no parser claims the extension, or the index predates the file.

graft blast · origin/main...HEAD · depth 2 · 7 changed files

Open the interactive graph → — click an area to see its dependent symbols at file:line.

github-actions Bot added a commit that referenced this pull request Sep 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant