fix(extraction): C# produces references edges for type annotations (#381) (#470)

Indexing any C# project produced zero `references` edges, so
`codegraph_callers SomeDto` returned no hits even when the DTO was used
as a param/return type across the codebase, and `codegraph_callees` on
a service class only saw its `using` imports — the headline structural
query silently degraded to text-search on half of every typical backend
stack.

Two root causes:

1. `csharp.ts` was missing `returnField` (default `'return_type'` doesn't
   exist on C# AST; the field is `'type'`) AND had
   `paramsField:'parameter_list'` (the node TYPE, not the field NAME
   `'parameters'`) — so parameter type extraction silently no-op'd.
2. `extractTypeRefsFromSubtree` only emitted refs for `type_identifier`
   leaves. C# tree-sitter doesn't produce `type_identifier` — it uses
   `identifier`, `predefined_type`, `qualified_name`, `generic_name`,
   `array_type`, `nullable_type`, `tuple_type`, etc.

Fix:

- `csharp.ts`: `paramsField:'parameters'`, `returnField:'type'`.
- Route C# through a dedicated `extractCsharpTypeRefs` +
  `walkCsharpTypePosition`. Descends ONLY into known type fields
  (`parameter.type`, `method.type`, `property.type`,
  `variable_declaration.type`, `tuple_element.type`), so parameter
  NAMES like `request` in `Build(UserDto request)` never leak as type
  refs.
- Hook `extractField` and `extractProperty` to call
  `extractTypeAnnotations` so property/field type refs land in the graph.

Validation on dotnet/eShop (527 .cs files):
  C# `references` edges: 35 -> 925 (+26x)
  No regression in calls/imports/instantiates/extends/implements.

Closes #381.
This commit is contained in:
Colby Mchenry
2026-05-26 17:23:17 -05:00
committed by GitHub
parent f1b79eeae1
commit 046e03a05f
4 changed files with 187 additions and 2 deletions
+2 -1
View File
@@ -18,7 +18,8 @@ export const csharpExtractor: LanguageExtractor = {
propertyTypes: ['property_declaration'],
nameField: 'name',
bodyField: 'body',
paramsField: 'parameter_list',
paramsField: 'parameters',
returnField: 'type',
getVisibility: (node) => {
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
+131 -1
View File
@@ -940,6 +940,10 @@ export class TreeSitterExtractor {
// decorator->target relationship for class properties too.
if (propNode) {
this.extractDecoratorsFor(node, propNode.id);
// Emit `references` edges from the property to types named in its
// type annotation (#381). The generic walker handles TS-style
// `type_annotation` children; the C# branch walks the `type` field.
this.extractTypeAnnotations(node, propNode.id);
}
}
@@ -1022,7 +1026,15 @@ export class TreeSitterExtractor {
});
// Java/Kotlin annotations / TS field decorators sit on the
// outer field_declaration, not on the individual declarator.
if (fieldNode) this.extractDecoratorsFor(node, fieldNode.id);
if (fieldNode) {
this.extractDecoratorsFor(node, fieldNode.id);
// Same as properties: emit `references` to the field's annotated
// type. The outer `field_declaration` is the right scope to
// search from — C# carries the `type` inside `variable_declaration`
// and the language-aware path in `extractTypeAnnotations` descends
// into that wrapper (#381).
this.extractTypeAnnotations(node, fieldNode.id);
}
}
} else {
// Fallback: try to find an identifier child directly
@@ -2219,6 +2231,17 @@ export class TreeSitterExtractor {
if (!this.extractor) return;
if (!this.TYPE_ANNOTATION_LANGUAGES.has(this.language)) return;
// C# tree-sitter doesn't produce `type_identifier` leaves — it uses
// `identifier`, `predefined_type`, `qualified_name`, `generic_name`,
// etc. — so the generic walker below emits zero references for it.
// Dispatch to a C#-aware path that only walks type-position subtrees
// (the `type` field of a parameter/method/property/field), so
// parameter NAMES never accidentally surface as type refs (#381).
if (this.language === 'csharp') {
this.extractCsharpTypeRefs(node, nodeId);
return;
}
// Extract parameter type annotations
const params = getChildByField(node, this.extractor.paramsField || 'parameters');
if (params) {
@@ -2240,6 +2263,113 @@ export class TreeSitterExtractor {
}
}
/**
* Extract C# type references from a node that owns a type position —
* a method/constructor declaration, a property declaration, or a
* field declaration (which wraps `variable_declaration → type`).
*
* Walks ONLY into known type fields, so parameter names like
* `request` in `Build(UserDto request)` are never mis-emitted as
* type references. Once inside a type subtree, `walkCsharpTypePosition`
* recognizes C#'s actual type-leaf node kinds (`identifier`,
* `qualified_name`, `generic_name`, `array_type`, `nullable_type`,
* `tuple_type`, …) — none of which are `type_identifier`. Closes #381.
*/
private extractCsharpTypeRefs(node: SyntaxNode, nodeId: string): void {
// Return type / property type — the field is named `type`.
const directType = getChildByField(node, 'type');
if (directType) this.walkCsharpTypePosition(directType, nodeId);
// Field declarations wrap declarators in a `variable_declaration`
// whose `type` field carries the type. The outer `field_declaration`
// has no `type` field of its own, so the call above is a no-op here
// and we descend one level.
const varDecl = node.namedChildren.find((c: SyntaxNode) => c.type === 'variable_declaration');
if (varDecl) {
const vdType = getChildByField(varDecl, 'type');
if (vdType) this.walkCsharpTypePosition(vdType, nodeId);
}
// Method / constructor parameters. The field name on
// `method_declaration` is `parameters`; it points at a
// `parameter_list` whose `parameter` children each have their own
// `type` field. Walking ONLY the type field skips parameter NAMES,
// which would otherwise mis-emit as type references.
const params = getChildByField(node, 'parameters');
if (params) {
for (let i = 0; i < params.namedChildCount; i++) {
const child = params.namedChild(i);
if (!child || child.type !== 'parameter') continue;
const paramType = getChildByField(child, 'type');
if (paramType) this.walkCsharpTypePosition(paramType, nodeId);
}
}
}
/**
* Walk a C# subtree that is KNOWN to be in a type position
* (return type, parameter type, property type, field type, generic
* argument). Identifiers here are type names, not parameter names.
*/
private walkCsharpTypePosition(node: SyntaxNode, fromNodeId: string): void {
// `predefined_type` is int/string/bool/etc. — never a project ref.
if (node.type === 'predefined_type') return;
// Bare type name: `Foo` in `Foo bar`, or the `Foo` inside `List<Foo>`.
if (node.type === 'identifier') {
const name = getNodeText(node, this.source);
if (name && !this.BUILTIN_TYPES.has(name)) {
this.unresolvedReferences.push({
fromNodeId,
referenceName: name,
referenceKind: 'references',
line: node.startPosition.row + 1,
column: node.startPosition.column,
});
}
return;
}
// `Namespace.Foo` → the rightmost identifier is the type. Emit the
// full qualified name as the reference; the resolver can still match
// on the trailing simple name when needed.
if (node.type === 'qualified_name') {
const text = getNodeText(node, this.source);
const last = text.split('.').pop() ?? text;
if (last && !this.BUILTIN_TYPES.has(last)) {
this.unresolvedReferences.push({
fromNodeId,
referenceName: last,
referenceKind: 'references',
line: node.startPosition.row + 1,
column: node.startPosition.column,
});
}
return;
}
// `(int Code, Foo Payload)` — tuple element has BOTH a `type` and a
// `name` field; descending into all named children would mis-emit
// the element name (`Code`, `Payload`) as a type ref. Walk only the
// type field.
if (node.type === 'tuple_element') {
const t = getChildByField(node, 'type');
if (t) this.walkCsharpTypePosition(t, fromNodeId);
return;
}
// Composite type nodes — recurse into named children. Covers
// `generic_name` (head identifier + `type_argument_list`),
// `nullable_type`, `array_type`, `pointer_type`, `tuple_type`,
// `ref_type`, and any newer wrapping shapes the grammar adds.
// Identifiers reached here are all type-positional (parameter/field
// names are gated out before we descend).
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child) this.walkCsharpTypePosition(child, fromNodeId);
}
}
/**
* Extract type references from a variable's type annotation.
*/