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
parent 0c7d0f7d42
commit 355afc70b1
8 changed files with 469 additions and 71 deletions
+77 -17
View File
@@ -5,8 +5,8 @@
//! path, bug-for-bug, verified by scripts/kernel-parity.mjs and the full-index
//! dump-diff gate. The authoritative quirk list is
//! docs/design/csharp-kernel-port-checklist.md — including every deliberate
//! emission hole (field/property initializers, constructor initializers,
//! delegates/events/operators/indexers, top-level locals) and garbage ref
//! emission hole (constructor initializers, delegates/events/operators/
//! indexers, top-level locals) and garbage ref
//! (`(repo)` primary-ctor extends, `: byte` enum extends, `nameof` calls)
//! this file preserves on purpose. Positions in UTF-16 code units. Files whose
//! parse tree contains ERRORS defer to the wasm extractor.
@@ -572,16 +572,18 @@ impl<'t> Walker<'t> {
self.extract_enum(node);
skip_children = true;
} else if kind == "property_declaration" && self.inside_class_like() {
// The code a property runs — its accessor bodies and `=> expr` —
// is walked with the property on the stack (propertyBodies). The
// candidates-only scan covers the rest (an `= initializer`),
// The code a property runs — its accessor bodies, `=> expr` and
// `= initializer` — is walked with the property on the stack
// (propertyBodies). The candidates-only scan covers the rest,
// skipping what the body walk captured.
let walked: Vec<usize> = match self.extract_property(node) {
Some((row, name)) => {
let bodies = property_bodies(node);
if !bodies.is_empty() {
self.stack.push(Scope { row, kind: "property", name });
let declared = node.child_by_field_name("type");
for body in &bodies {
self.extract_target_typed_new(Some(*body), declared);
self.visit_function_body(*body);
}
self.stack.pop();
@@ -593,8 +595,11 @@ impl<'t> Walker<'t> {
self.scan_fn_ref_subtree(node, 0, &walked);
skip_children = true;
} else if kind == "field_declaration" && self.inside_class_like() {
self.extract_field(node);
self.scan_fn_ref_subtree(node, 0, &[]);
// Each declarator, `= initializer` included, is walked with its
// field on the stack (extract_field); the candidates-only scan
// covers the rest, skipping the declarators that walk captured.
let walked = self.extract_field(node);
self.scan_fn_ref_subtree(node, 0, &walked);
skip_children = true;
} else if kind == "local_declaration_statement" && !self.inside_class_like() {
// Top-level statements: extractVariable's generic fallback finds no
@@ -864,8 +869,11 @@ impl<'t> Walker<'t> {
}
/// extractField (2046) — field_declaration; each declarator becomes a
/// field/constant node anchored at the DECLARATOR.
fn extract_field(&mut self, node: Node<'t>) {
/// field/constant node anchored at the DECLARATOR, and the declarator —
/// its `= initializer` — is walked with that node on the stack. Returns
/// the walked declarators, for the fn-ref scan to skip.
fn extract_field(&mut self, node: Node<'t>) -> Vec<usize> {
let mut walked = Vec::new();
let docstring = preceding_docstring(node, self.src);
let visibility = Some(self.visibility_of(node));
let is_static = Some(self.is_static(node));
@@ -932,6 +940,15 @@ impl<'t> Walker<'t> {
// multi-declarator fields emit the type refs once PER
// declarator, each from its own field node.
self.extract_csharp_type_refs(node, row);
// The initializer is the declarator's last, unnamed child:
// its calls, instantiations, static reads and fn-ref
// candidates (varinit included) are the field's.
self.stack.push(Scope { row, kind: field_kind, name });
let declared = var_decl.and_then(|vd| vd.child_by_field_name("type"));
self.extract_target_typed_new(last_named_child(decl), declared);
self.visit_function_body(decl);
self.stack.pop();
walked.push(decl.id());
}
}
} else {
@@ -951,6 +968,44 @@ impl<'t> Walker<'t> {
);
}
}
walked
}
/// extractTargetTypedNew (tree-sitter.ts) — a target-typed `new()`
/// (implicit_object_creation_expression) names no type, so it is no
/// instantiation kind; as a field's or property's initializer it
/// constructs the declared type (`List<Foo> _items = new();` → List).
/// Emitted from the stack top (the member) at the `new()`.
fn extract_target_typed_new(&mut self, value: Option<Node<'t>>, declared: Option<Node<'t>>) {
let Some(value) = value else { return };
if value.kind() != "implicit_object_creation_expression" || self.stack.is_empty() {
return;
}
let Some(class_name) = declared.and_then(|t| self.class_type_name(t)) else { return };
let from = self.top_row();
self.push_ref_at(from, &class_name, edge_kind_index("instantiates").unwrap(), value);
}
/// csharpClassTypeName (tree-sitter.ts) — the class a declared type
/// names, as `new T()` would name it: `List<Foo>` → List, `Ns.Foo` /
/// `global::Foo` → Foo, `Foo?` → Foo. Predefined, array, tuple and pointer
/// types name no class.
fn class_type_name(&self, node: Node) -> Option<String> {
match node.kind() {
"identifier" => {
let text = self.text(node);
(!text.is_empty()).then(|| text.to_string())
}
"generic_name" => {
let ident = (0..node.named_child_count())
.filter_map(|i| node.named_child(i))
.find(|c| c.kind() == "identifier")?;
self.class_type_name(ident)
}
"qualified_name" | "alias_qualified_name" => self.class_type_name(node.child_by_field_name("name")?),
"nullable_type" => self.class_type_name(node.child_by_field_name("type")?),
_ => None,
}
}
/// extractMethod (1737) — method_declaration + constructor_declaration.
@@ -1142,7 +1197,8 @@ impl<'t> Walker<'t> {
let Some(ctor) = ctor else { return };
// `new List<Foo>()` → `List`; `new Ns.Foo()` → `Foo`. Target-typed
// `new()` / anonymous `new { }` / arrays `new T[n]` never reach here
// (not in INSTANTIATION_KINDS) — invisible by design.
// (not in INSTANTIATION_KINDS) — invisible by design, except a `new()`
// that initializes a field or property (extract_target_typed_new).
let class_name = strip_generic_and_qualifier(self.text(ctor));
if !class_name.is_empty() {
let from = self.top_row();
@@ -1492,8 +1548,9 @@ impl<'t> Walker<'t> {
return;
}
// functionTypes is EMPTY for C#; the literal halt list applies —
// lambda_expression IS C#'s lambda, so initializer lambdas stop the
// scan; anonymous_method_expression is NOT listed and scans through.
// lambda_expression IS C#'s lambda, so a lambda the scan reaches (a
// top-level statement's) stops it; anonymous_method_expression is NOT
// listed and scans through. Member initializers are walked instead.
if depth > 0
&& matches!(
node.kind(),
@@ -1643,8 +1700,9 @@ impl<'t> Walker<'t> {
}
/// propertyBodies (tree-sitter.ts) — the parts of a property that run code:
/// each accessor's `body` (a block or `=> expr`) and an expression-bodied
/// property's `=> …` value. An `= initializer` value is not a body.
/// each accessor's `body` (a block or `=> expr`), then the property's
/// `value` — an expression body's `=> …` or an `= initializer`. Attributes
/// are not walked.
fn property_bodies(node: Node) -> Vec<Node> {
let mut bodies = Vec::new();
if let Some(accessors) = node.child_by_field_name("accessors") {
@@ -1659,13 +1717,15 @@ fn property_bodies(node: Node) -> Vec<Node> {
}
}
if let Some(value) = node.child_by_field_name("value") {
if value.kind() == "arrow_expression_clause" {
bodies.push(value);
}
bodies.push(value);
}
bodies
}
fn last_named_child(node: Node) -> Option<Node> {
node.named_child(node.named_child_count().checked_sub(1)?)
}
fn find_anonymous_class_body(node: Node) -> Option<Node> {
for i in 0..node.named_child_count() {
if let Some(child) = node.named_child(i) {