fix(csharp): walk field and property initializers; a constant never names a type (#2337) (#2348)

* fix(csharp): walk field and property initializers as the member

Field declarators and property `= initializer` values were skipped by
both extractors, so `private readonly ILogger _log =
LogManager.GetLogger(typeof(X));` linked nothing, and a method or class
used only from an initializer looked unused.

- tree-sitter.ts / csharp.rs: walk each C# field declarator whole with
  its field on the stack, and every property `value:` (not only
  `=> expr`) with the property on the stack. Calls, instantiations,
  static reads, lambda bodies and fn-ref candidates (varinit included)
  belong to the member; the fn-ref scan skips the walked subtrees, so
  each candidate is captured once. Attributes stay unwalked.
- A target-typed `new()` that is the initializer instantiates the
  declared type (`List<Foo> _items = new();` -> List). In bodies it
  stays invisible, as before.
- name-matcher.ts: a .NET type position never names a `constant` (the
  kind `const` / `static readonly` fields get). Walking initializers
  exposed `new Version(5, 18)` binding to a `const string Version` and
  `static readonly Meter Meter = new(...)` instantiating itself.

Kernel/wasm parity: 0 diffs on serilog, Newtonsoft.Json and jellyfin,
and full-index dumps byte-identical between the two arms. No
EXTRACTION_VERSION bump: #2345 already moved it to 28 this release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(changelog): credit the report and contributor behind the constant change

The type-position change here is what issue #2337 reports (`new Station
{ … }` unresolved beside a `private const string Station`) and the same
one-line change as @drakeo338's #2339. Verified on the issue's own
three-file repro: the `instantiates` edge to `Demo.Core::Station`, missing
on main, is present with this branch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-05 17:20:15 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 0c7d0f7d42
commit 355afc70b1
8 changed files with 469 additions and 71 deletions
+85 -18
View File
@@ -239,6 +239,29 @@ function scalaBaseTypeName(node: SyntaxNode | null, source: string): string | nu
}
}
/**
* The class a C# declared type names, as `new T()` would name it: `Foo`,
* `List<Foo>` → `List`, `Ns.Foo` / `global::Foo` → `Foo`, `Foo?` → `Foo`.
* Predefined, array, tuple and pointer types name no class → null. Mirrored
* in the kernel (csharp.rs class_type_name).
*/
function csharpClassTypeName(node: SyntaxNode | null, source: string): string | null {
if (!node) return null;
switch (node.type) {
case 'identifier':
return getNodeText(node, source) || null;
case 'generic_name':
return csharpClassTypeName(node.namedChildren.find((c: SyntaxNode) => c.type === 'identifier') ?? null, source);
case 'qualified_name':
case 'alias_qualified_name':
return csharpClassTypeName(getChildByField(node, 'name'), source);
case 'nullable_type':
return csharpClassTypeName(getChildByField(node, 'type'), source);
default:
return null;
}
}
/**
* Resolve the declared identifier inside a C declarator. A `declaration`'s
* `declarator` field nests the name through `init_declarator` (with value),
@@ -1287,22 +1310,27 @@ export class TreeSitterExtractor {
const bodies = propNode ? this.propertyBodies(node) : [];
if (propNode && bodies.length > 0) {
this.nodeStack.push(propNode.id);
for (const body of bodies) this.visitFunctionBody(body, propNode.id);
const declaredType = getChildByField(node, 'type');
for (const body of bodies) {
this.extractTargetTypedNew(body, declaredType);
this.visitFunctionBody(body, propNode.id);
}
this.nodeStack.pop();
}
// Whatever the body walk didn't cover (a C# `= initializer`, any other
// language's whole declaration) is scanned for function-as-value
// Whatever the body walk didn't cover (a C# property's attributes, any
// other language's whole declaration) is scanned for function-as-value
// candidates (#756); the bodies captured their own.
this.scanFnRefSubtree(node, 0, new Set(bodies.map((b) => b.id)));
skipChildren = true;
}
// Check for class fields (e.g. Java field_declaration, C# field_declaration)
else if (this.extractor.fieldTypes?.includes(nodeType) && this.isInsideClassLikeNode()) {
this.extractField(node);
// Field initializers aren't walked — scan for function-as-value
// candidates (#756): Java `List<IntConsumer> table = List.of(Main::cb)`,
// C# `List<Action<int>> table = new() { TargetCb }`.
this.scanFnRefSubtree(node, 0);
const walked = this.extractField(node);
// Scan the declaration for function-as-value candidates (#756): Java
// `List<IntConsumer> table = List.of(Main::cb)`. A C# declarator
// extractField walked captured its own (`List<Action<int>> table =
// new() { TargetCb }` is the field's), so the scan skips it.
this.scanFnRefSubtree(node, 0, walked);
skipChildren = true;
}
// Check for variable declarations (const, let, var, etc.)
@@ -2324,9 +2352,9 @@ export class TreeSitterExtractor {
* VB.NET writes its `Get` / `Set` blocks, `= initializer` and `As New T`
* as children of the declaration itself, so the declaration is walked
* whole, the way its methods are (resolveBody). C# runs code in each
* accessor's body (`get { … }`, `set => …`) and in an expression-bodied
* property's `=> …`; its `= initializer`, like a field's, stays unwalked.
* Mirrored in the kernel (csharp.rs property_bodies).
* accessor's body (`get { … }`, `set => …`) and in the property's `value`:
* an expression body's `=> …` or an `= initializer`. Its attributes are
* not walked. Mirrored in the kernel (csharp.rs property_bodies).
*/
private propertyBodies(node: SyntaxNode): SyntaxNode[] {
if (this.language === 'vbnet') return [node];
@@ -2337,16 +2365,41 @@ export class TreeSitterExtractor {
if (body) bodies.push(body);
}
const value = getChildByField(node, 'value');
if (value?.type === 'arrow_expression_clause') bodies.push(value);
if (value) bodies.push(value);
return bodies;
}
/**
* A C# target-typed `new()` names no type, which is why INSTANTIATION_KINDS
* leaves `implicit_object_creation_expression` out. As a field's or
* property's initializer, though, it constructs the declared type:
* `private readonly List<Foo> _items = new();` instantiates List, as
* `new List<Foo>()` does. Emitted from the node-stack top (the member).
* Mirrored in the kernel (csharp.rs extract_target_typed_new).
*/
private extractTargetTypedNew(value: SyntaxNode | null, declaredType: SyntaxNode | null): void {
if (this.language !== 'csharp' || value?.type !== 'implicit_object_creation_expression') return;
const className = declaredType ? csharpClassTypeName(declaredType, this.source) : null;
const fromNodeId = this.nodeStack[this.nodeStack.length - 1];
if (!className || !fromNodeId) return;
this.unresolvedReferences.push({
fromNodeId,
referenceName: className,
referenceKind: 'instantiates',
line: value.startPosition.row + 1,
column: value.startPosition.column,
});
}
/**
* Extract a class field declaration (e.g. Java field_declaration, C# field_declaration).
* Extracts each declarator as a 'field' kind node inside the owning class.
* Returns the C# declarators it walked, for the function-as-value scan to
* skip.
*/
private extractField(node: SyntaxNode): void {
if (!this.extractor) return;
private extractField(node: SyntaxNode): Set<number> {
const walked = new Set<number>();
if (!this.extractor) return walked;
const docstring = this.docstringFor(node);
const visibility = this.extractor.getVisibility?.(node);
@@ -2401,7 +2454,7 @@ export class TreeSitterExtractor {
isStatic,
});
}
return;
return walked;
}
}
@@ -2447,11 +2500,24 @@ export class TreeSitterExtractor {
// edge at all and `target` looked callerless. Keyed on the `value`
// FIELD, which only Java's `variable_declarator` carries. VB.NET
// writes `= expr` (the declarator's `initializer`) or `As New T(…)`
// (inside its as_clause), so its whole declarator is walked. C#
// and PHP spell their initializer differently and are untouched.
const valueNode = this.language === 'vbnet' ? decl : getChildByField(decl, 'value');
// (inside its as_clause), and C# writes `= expr` as the
// declarator's last, unnamed child, so both walk the whole
// declarator. PHP spells its initializer differently and is
// untouched.
const wholeDeclarator = this.language === 'vbnet' || this.language === 'csharp';
const valueNode = wholeDeclarator ? decl : getChildByField(decl, 'value');
if (valueNode) {
this.nodeStack.push(fieldNode.id);
if (this.language === 'csharp') {
this.extractTargetTypedNew(
decl.namedChild(decl.namedChildCount - 1),
varDecl ? getChildByField(varDecl, 'type') : null,
);
// Its function-as-value candidates are the field's, captured
// here once. (VB.NET captures none; Java's scan still takes
// its initializers for the class as well.)
walked.add(decl.id);
}
this.visitFunctionBody(valueNode, fieldNode.id);
this.nodeStack.pop();
}
@@ -2470,6 +2536,7 @@ export class TreeSitterExtractor {
});
}
}
return walked;
}
/**
+6 -2
View File
@@ -2438,10 +2438,14 @@ function isDotNetTypeRef(ref: UnresolvedRef, context: ResolutionContext): boolea
* method (a constructor is one) or an enum case shares the type's name, not
* its meaning: AutoMapper's `Type sourceType` bound to an attribute's `Type`
* property and `TypeMap typeMap` to a `TypeMap` property beside the `TypeMap`
* class. A field never names a type either.
* class. A field never names a type either, nor does a constant — the kind a
* C# `const` / `static readonly` field gets: jellyfin's `new Version(5, 18)`
* bound to a claim-name `const string Version`, and serilog's `static
* readonly Meter Meter = new(…)` to itself.
*/
function canNameInTypePosition(n: Node): boolean {
return !(n.kind === 'property' || n.kind === 'method' || n.kind === 'enum_member' || n.kind === 'field');
return !(n.kind === 'property' || n.kind === 'method' || n.kind === 'enum_member' || n.kind === 'field' ||
n.kind === 'constant');
}
/**