From 355afc70b14c22713fee491eafb881b577d34ef1 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Mon, 5 Oct 2026 17:20:15 +0000 Subject: [PATCH] fix(csharp): walk field and property initializers; a constant never names a type (#2337) (#2348) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 _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 * 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 --------- Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 2 + __tests__/csharp-member-initializers.test.ts | 155 +++++++++++++++++++ __tests__/csharp-type-position-refs.test.ts | 42 +++++ __tests__/kernel-csharp-parity.test.ts | 40 ++++- codegraph-kernel/src/csharp.rs | 94 +++++++++-- docs/design/csharp-kernel-port-checklist.md | 96 ++++++++---- src/extraction/tree-sitter.ts | 103 +++++++++--- src/resolution/name-matcher.ts | 8 +- 8 files changed, 469 insertions(+), 71 deletions(-) create mode 100644 __tests__/csharp-member-initializers.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 68d7cc062..da460b5b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In VB.NET, every member of a `Structure` is now indexed, including its fields, properties, methods, constructors and nested enums. Before, only the first member was, so the rest could not be found and their callers looked empty. - In VB.NET and C#, what a property's `Get` and `Set` code calls, creates and reads now belongs to that property, as do C#'s `get => …` accessors and `=> …` property bodies. Before, it was dropped, so a method used only from a property looked unused. - In VB.NET, a field or property initializer like `= Compute()` or `As New List(Of Order)` now links what it calls and creates, and so do a `Custom Event`'s `AddHandler`, `RemoveHandler` and `RaiseEvent` blocks. Re-index VB.NET and C# projects after upgrading. +- In C#, a field or property initializer like `private readonly ILogger _log = LogManager.GetLogger(typeof(X));` or `public List Items { get; } = new();` now links what it calls, creates and reads, including inside a lambda, and a target-typed `new()` there counts as creating the declared type. Before, initializers were skipped, so a method or class used only from one — like a converter created in a static list — looked unused, and a method passed as a value there was credited to the whole class instead of the field or property. +- In C# and VB.NET, a constant no longer stands in for a type with the same name: `new Station { … }` links to class `Station` even when another class declares `private const string Station`, `new Version(…)` no longer links to a `const string Version`, and a field like `static readonly Meter Meter` no longer points at itself. Thanks @EvanYu1980 for the report and @drakeo338. (#2337) - Upgrading CodeGraph while an agent session is open no longer leaves the old version's background server in charge of your project: the first session started from the new install stops it and starts a current one in its place, even while sessions opened before the upgrade are still running. That old server could no longer load the language parsers the upgrade removed, so it saved every file it re-indexed with no symbols, while new sessions could only read the index beside it without keeping it up to date. A background server from a newer install is never stopped, and sessions opened before the upgrade keep the old version until you restart them. Thanks @lipchey for the report. (#2335) - A file is no longer saved with no symbols when its language parser can't be loaded, which is what happened to every file a background server re-indexed after an upgrade removed its install: the file keeps what it had and is indexed again once the parser loads, and files an earlier version emptied this way are re-indexed by the next sync. A background server also exits on its own once its install is upgraded or removed, so the next session starts one from the current install. Thanks @lipchey for the report. (#2335) - `codegraph status` no longer says the index is up to date while indexed files are missing their symbols: it now names files the parser couldn't read and files stored without their symbols (which `codegraph sync` repairs), `status --json` counts both, and `codegraph files --json` lists each file's recorded errors. Thanks @lipchey for the report. (#2336) diff --git a/__tests__/csharp-member-initializers.test.ts b/__tests__/csharp-member-initializers.test.ts new file mode 100644 index 000000000..f5125b832 --- /dev/null +++ b/__tests__/csharp-member-initializers.test.ts @@ -0,0 +1,155 @@ +/** + * C# field and property initializers belong to the member they initialize. + * `private readonly ILogger _log = LogManager.GetLogger(typeof(X));` and + * `public List Items { get; } = new List();` used to be skipped by + * both extractors, so the calls, instantiations and static reads written + * there were lost, and a method passed as a value there was the class's + * reference rather than the member's. + * + * A target-typed `new()` names no type of its own; as an initializer it + * constructs the member's declared type. + * + * Runs against the native kernel (when built) and the wasm extractor, which + * must agree. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; +import type { Node } from '../src/types'; + +const FILES: Record = { + 'Lib.cs': `namespace Lib +{ + public class Crate { } +} +public interface ILogger { } +public static class LogManager +{ + public static ILogger GetLogger(System.Type t) => null; +} +public static class Helper +{ + public static int Compute(int n) => n; + public static int Seed() => 1; + public static void Register(System.Action a) { } + public static System.Action Wrap(System.Action a) => a; +} +public class Widget +{ + public Widget() { } + public Widget(int n) { } + public int Size { get; set; } +} +public class Bag { } +public static class Defaults +{ + public const string Name = "x"; + public static readonly Widget Empty = new Widget(); +} +`, + 'Box.cs': `using Lib; + +public class Box +{ + private readonly ILogger _log = LogManager.GetLogger(typeof(Box)); + private int _a = 1, _b = Helper.Compute(2); + private static readonly int Max = Helper.Seed(); + private readonly Widget _made = new Widget(3); + private readonly Widget _typed = new() { Size = Helper.Seed() }; + private Widget? _maybe = new(); + private readonly Bag _bag = new(); + private readonly Lib.Crate _crate = new(); + private readonly string _label = Defaults.Name; + private readonly System.Func _square = x => Helper.Compute(x * x); + private readonly System.Action _later = () => Helper.Register(Handle); + private readonly System.Action _direct = Handle; + [System.Obsolete("use Helper.Seed()")] private int _flagged; + public Bag Items { get; } = new Bag(); + public Widget Made { get; set; } = new(); + public Widget Copy { get; } = Defaults.Empty; + public System.Action Wrapped { get; } = Helper.Wrap(Handle); + public int Seeded { get; } = Helper.Seed(); + private static void Handle() { } +} +`, +}; + +describe('C# field and property initializers', () => { + let root = ''; + let cg: CodeGraph | undefined; + let kernel: string | undefined; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-cs-initializers-')); + for (const [rel, content] of Object.entries(FILES)) fs.writeFileSync(path.join(root, rel), content); + kernel = process.env.CODEGRAPH_KERNEL; + }); + + afterEach(() => { + cg?.close(); + cg = undefined; + fs.rmSync(root, { recursive: true, force: true }); + if (kernel === undefined) delete process.env.CODEGRAPH_KERNEL; + else process.env.CODEGRAPH_KERNEL = kernel; + }); + + it.each(['default', 'wasm'])('are walked as the member they initialize (%s)', async (backend) => { + if (backend === 'wasm') process.env.CODEGRAPH_KERNEL = '0'; + else delete process.env.CODEGRAPH_KERNEL; + cg = await CodeGraph.init(root, { index: true }); + const graph = cg; + const member = (qualifiedName: string): Node => { + const node = graph.getNodesInFile('Box.cs').find((n) => n.qualifiedName === qualifiedName); + expect(node, qualifiedName).toBeDefined(); + return node!; + }; + const targets = (node: Node, kind: string): string[] => + graph + .getOutgoingEdgesFrom([node.id]) + .filter((e) => e.kind === kind) + .map((e) => graph.getNode(e.target)!.qualifiedName) + .sort(); + + // Calls, per declarator. + expect(targets(member('Box::_log'), 'calls')).toEqual(['LogManager::GetLogger']); + expect(targets(member('Box::_a'), 'calls')).toEqual([]); + expect(targets(member('Box::_b'), 'calls')).toEqual(['Helper::Compute']); + expect(member('Box::Max').kind).toBe('constant'); + expect(targets(member('Box::Max'), 'calls')).toEqual(['Helper::Seed']); + expect(targets(member('Box::Seeded'), 'calls')).toEqual(['Helper::Seed']); + + // Instantiations, including a target-typed `new()` of the declared type. + expect(targets(member('Box::_made'), 'instantiates')).toEqual(['Widget']); + expect(targets(member('Box::_typed'), 'instantiates')).toEqual(['Widget']); + expect(targets(member('Box::_typed'), 'calls')).toEqual(['Helper::Seed']); + expect(targets(member('Box::_maybe'), 'instantiates')).toEqual(['Widget']); + expect(targets(member('Box::_bag'), 'instantiates')).toEqual(['Bag']); + expect(targets(member('Box::_crate'), 'instantiates')).toEqual(['Lib::Crate']); + expect(targets(member('Box::Items'), 'instantiates')).toEqual(['Bag']); + expect(targets(member('Box::Made'), 'instantiates')).toEqual(['Widget']); + + // Static reads. + expect(targets(member('Box::_label'), 'references')).toContain('Defaults'); + expect(targets(member('Box::Copy'), 'references')).toContain('Defaults'); + + // Lambda bodies belong to the member the lambda initializes. + expect(targets(member('Box::_square'), 'calls')).toEqual(['Helper::Compute']); + expect(targets(member('Box::_later'), 'calls')).toEqual(['Helper::Register']); + + // A method passed as a value is the member's reference, captured once — + // not the class's as well. + expect(targets(member('Box::_later'), 'references')).toContain('Box::Handle'); + expect(targets(member('Box::_direct'), 'references')).toContain('Box::Handle'); + expect(targets(member('Box::Wrapped'), 'calls')).toEqual(['Helper::Wrap']); + expect(targets(member('Box::Wrapped'), 'references')).toContain('Box::Handle'); + expect(targets(member('Box'), 'references')).not.toContain('Box::Handle'); + + // Attribute arguments are not an initializer, and the class itself + // calls and creates nothing. + expect(targets(member('Box::_flagged'), 'calls')).toEqual([]); + expect(targets(member('Box'), 'calls')).toEqual([]); + expect(targets(member('Box'), 'instantiates')).toEqual([]); + }); +}); diff --git a/__tests__/csharp-type-position-refs.test.ts b/__tests__/csharp-type-position-refs.test.ts index c000d9210..ccfba566b 100644 --- a/__tests__/csharp-type-position-refs.test.ts +++ b/__tests__/csharp-type-position-refs.test.ts @@ -69,4 +69,46 @@ describe('C#: a type position names a type', () => { cg.close(); } }); + + it('never a constant that shares the name', async () => { + // `const` and `static readonly` fields are constants: jellyfin's `new + // Version(5, 18)` (System.Version) bound to a `const string Version` + // claim name, and serilog's `static readonly Meter Meter = new(…)` + // instantiated itself. + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-cs-type-refs-')); + roots.push(root); + const files: Record = { + 'src/Claims.cs': `namespace App; +public static class ClaimTypes { + public const string Version = "v"; +} +`, + 'src/Encoder.cs': `namespace App; +using System; +public class ValueFormatter { } +public class Encoder { + private static readonly Version MinVersion = new Version(5, 18); + private static readonly Meter Meter = new("app"); + private static readonly ValueFormatter ValueFormatter = new(); + public void Check() { var v = new Version(1, 0); } +} +`, + }; + for (const [rel, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + } + const cg = await CodeGraph.init(root, { index: true }); + try { + const members = cg.getNodesInFile('src/Encoder.cs').filter((n) => n.kind !== 'file'); + const targets = cg + .getOutgoingEdgesFrom(members.map((n) => n.id), ['references', 'instantiates', 'type_of']) + .map((e) => `${cg.getNode(e.source)!.name} ${e.kind} ${cg.getNode(e.target)!.kind}:${cg.getNode(e.target)!.qualifiedName}`) + .sort(); + expect(targets.filter((t) => t.includes(' constant:'))).toEqual([]); + expect(targets).toContain('ValueFormatter instantiates class:App::ValueFormatter'); + } finally { + cg.close(); + } + }); }); diff --git a/__tests__/kernel-csharp-parity.test.ts b/__tests__/kernel-csharp-parity.test.ts index 3d09c19d9..7a1537189 100644 --- a/__tests__/kernel-csharp-parity.test.ts +++ b/__tests__/kernel-csharp-parity.test.ts @@ -8,12 +8,12 @@ * * - Torture.cs — block namespace + nested/second-namespace quirks, * base_list shapes, records, properties (incl. the bare-identifier - * signature loss, accessor bodies walked as the property and - * never-walked initializers), fields/constants, + * signature loss; accessor bodies and initializers walked as the + * property), fields/constants (initializers walked as the field), * events/operators/indexer/destructor (no nodes, calls → class), ctor * initializer hole, explicit interface impl, local functions, the call * zoo (raw member-access texts, chained re-encode, `(myDel)(x)` conv, - * `nameof`), instantiation shapes (incl. invisible `new()`/`new {}`/ + * `nameof`), instantiation shapes (incl. invisible body `new()`/`new {}`/ * arrays), static value reads, C# type refs, fn-ref candidates * (`+=` subscription, `this.X` bare-name form, initializer lists), * value-ref targets + local shadow prune, preprocessor passthrough. @@ -169,10 +169,9 @@ describe.skipIf(!kernelBuilt)('kernel C# extraction parity', () => { minNodes: 2, }, { - // Accessor bodies and `=> expr` are walked as the property (calls, - // instantiations, static reads, fn-ref candidates); an `= initializer` - // is only scanned for candidates, attributed to the class. - name: 'property bodies are walked as the property; initializers only scanned', + // Accessor bodies, `=> expr` and `= initializer` are walked as the + // property (calls, instantiations, static reads, fn-ref candidates). + name: 'property bodies and initializers are walked as the property', source: [ 'public class C {', ' void H(int v) { }', @@ -185,6 +184,33 @@ describe.skipIf(!kernelBuilt)('kernel C# extraction parity', () => { ].join('\n'), minNodes: 7, }, + { + // Each field declarator and each property `= initializer` is walked + // with its member on the stack — lambdas included — and its fn-ref + // candidates (varinit too) are the member's, captured once. A + // target-typed `new()` there instantiates the declared type; attribute + // arguments stay with the class's candidates-only scan. + name: 'field and property initializers are walked as the member', + source: [ + 'public class C {', + ' static void H() { }', + ' private readonly ILogger _log = LogManager.GetLogger(typeof(C));', + ' int a = 1, b = Calc.Max(2);', + ' private static readonly List Table = new() { H };', + ' private Widget? _w = new(1) { Size = Make() };', + ' private global::Ns.Box _g = new(), _h = (new());', + ' private (int, int) _t = new();', + ' Action g = () => Register(H);', + ' Del d = H;', + ' public Widget P { get; } = new();', + ' public Action R { get; } = Wrap(H);', + ' public string S { get; } = Defaults.Name;', + ' [Attr(Make(H))] public int T { get; set; } = 3;', + '}', + '', + ].join('\n'), + minNodes: 17, + }, ]; for (const m of MICROS) { diff --git a/codegraph-kernel/src/csharp.rs b/codegraph-kernel/src/csharp.rs index 04e20ca5a..c10696ec9 100644 --- a/codegraph-kernel/src/csharp.rs +++ b/codegraph-kernel/src/csharp.rs @@ -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 = 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 { + 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 _items = new();` → List). + /// Emitted from the stack top (the member) at the `new()`. + fn extract_target_typed_new(&mut self, value: Option>, declared: Option>) { + 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` → 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 { + 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()` → `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 { 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 { } } 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.named_child(node.named_child_count().checked_sub(1)?) +} + fn find_anonymous_class_body(node: Node) -> Option { for i in 0..node.named_child_count() { if let Some(child) = node.named_child(i) { diff --git a/docs/design/csharp-kernel-port-checklist.md b/docs/design/csharp-kernel-port-checklist.md index e817968c9..919e76768 100644 --- a/docs/design/csharp-kernel-port-checklist.md +++ b/docs/design/csharp-kernel-port-checklist.md @@ -234,8 +234,8 @@ all PRESERVE): | `enum_declaration` | enumTypes:1064 → extractEnum:1914 | body `enum_member_declaration_list` required (bodiless → no node); extractInheritance sees `base_list` → **the underlying type `: byte` emits an `extends` ref named `byte`** (quirk, §inheritance); `enum_member_declaration` children → extractEnumMembers:1958 — `name` field path: ONE `enum_member` node per member, positioned at the member node (attributes included in its span), values/attributes ignored; non-member children (preproc_*, comment) → visitNode (no-op) | | `method_declaration` | methodTypes:1027 → extractMethod:1737 | classifyMethodNode absent → always extractMethod. Gate 1747 passes via class-like (a method_declaration outside a type does not occur in non-erroring C# — top-level `void M(){}` parses as local_function_statement, probed); bodyless interface/partial signatures mint nodes with no body walk; **expression-bodied methods have `body: arrow_expression_clause` (a real body FIELD, probed) → walked** | | `constructor_declaration` | methodTypes → extractMethod | name field = the class-name identifier → **method node named like the class**; returnType undefined; **`constructor_initializer` (`: base(args)` / `: this(args)`) is a sibling of the body field → NEVER walked → calls inside initializer args are LOST** (probed); expression-bodied ctor body = arrow_expression_clause → walked | -| `property_declaration` (inside class-like) | propertyTypes:1075 → extractProperty:1986 | property node, then **propertyBodies — each accessor's `body` (`get { … }`, `set => …`) and an expression-bodied `=> expr` `value:` — walked by visitFunctionBody with the property pushed** (calls, instantiates, static reads and fn-ref candidates attribute to the property; changed 2026-10-04, previously never walked), then scanFnRefSubtree (capture-only, attributed to the class) over the rest — **the `= initializer` stays unwalked** — skipping the walked bodies, + skipChildren. §property below | -| `field_declaration` (inside class-like) | fieldTypes:1084 → extractField:2046 | field/constant nodes per declarator + scanFnRefSubtree + skipChildren → **field initializers emit no calls/instantiates/static-member refs** (fn-ref candidates only). §field below | +| `property_declaration` (inside class-like) | propertyTypes:1075 → extractProperty:1986 | property node, then **propertyBodies — each accessor's `body` (`get { … }`, `set => …`), then the `value:` (an expression body's `=> expr` or an `= initializer`) — walked by visitFunctionBody with the property pushed** (calls, instantiates, static reads and fn-ref candidates attribute to the property; accessor/arrow bodies changed 2026-10-04, `= initializer` 2026-10-05 — previously never walked); a target-typed `= new()` value instantiates the declared type (extractTargetTypedNew); then scanFnRefSubtree (capture-only, attributed to the class) over the rest (attributes, type) skipping the walked bodies, + skipChildren. §property below | +| `field_declaration` (inside class-like) | fieldTypes:1084 → extractField:2046 | field/constant nodes per declarator, **each declarator walked whole by visitFunctionBody with its field pushed** (its `= initializer`: calls, instantiates, static reads, fn-ref candidates incl. varinit attribute to the field; changed 2026-10-05, previously never walked); a target-typed `= new()` instantiates the declared type; then scanFnRefSubtree (capture-only, attributed to the class) skipping the walked declarators, + skipChildren. §field below | | `local_declaration_statement` | variableTypes:1098 (only reachable at top level — global statements; body locals go through visitFunctionBody instead) | not class-like → extractVariable:2538 → **generic fallback (2863-2881) finds no direct `identifier`/`variable_declarator` children (the declarator nests inside `variable_declaration`, probed) → ZERO nodes minted**; isClassScopeConstantAssignment (1508) needs node.type `assignment` → never true. skipChildren=true + scanFnRefSubtree → **a top-level `var builder = WebApplication.CreateBuilder(args);` produces NO node, NO calls ref, NO instantiates** — only fn-ref candidates. PRESERVE | | `using_directive` | importTypes:1209 → extractImport:3170 | hook (§config) → import node + ONE generic `imports` ref {fromNodeId: nodeStack top (namespace node if present, else file), referenceName: moduleName, line/col of the directive}; **no per-binding emitter** (the TS/py/rust/php/ruby ladder at 3197-3234 excludes csharp) | | `invocation_expression` (top level — global statements) | callTypes:1248 → extractCall:3684 | fires with caller = file/namespace node; children still visited (no skipChildren) so nested invocations recurse | @@ -306,9 +306,10 @@ for C#) or bare `name`. QUIRKS (probed, PRESERVE): children [modifier, predefined_type, identifier, **arrow_expression_clause (`value:` field)**] → typeNode = predefined_type → signature `"int Computed"`; the arrow clause is a property body (walked as the property, below). -- `{ get; } = new();` initializers: the `value:` implicit_object_creation is - NOT a body → no refs, no instantiates (candidates-only scan, attributed to - the class); the accessor_list is excluded from the type scan. +- `{ get; } = new();` initializers: the `value:` is the + implicit_object_creation_expression itself (no type child) → walked as a + body, and extractTargetTypedNew emits `instantiates` named for the + declared `type` (below); the accessor_list is excluded from the type scan. Then extractDecoratorsFor (no-op) and **extractTypeAnnotations (2037) → extractCsharpTypeRefs** — the `type` field IS walked for refs (so `public @@ -316,10 +317,22 @@ List Items` emits references `List` + `Foo` even though the signature kept the raw text). The returned node is pushed while propertyBodies (tree-sitter.ts; csharp.rs `property_bodies`) are walked: every `accessor_declaration`'s `body` field (block or arrow_expression_clause, in -accessor order), then the property's `value:` when it is an -arrow_expression_clause. The fn-ref scan that follows skips those subtrees -by node id, so a candidate is captured once — from the property when it sits -in a body, from the class when it sits in an initializer. (The +accessor order), then the property's `value:` whatever it is (an +arrow_expression_clause or an `= initializer` expression). Each body is +preceded by **extractTargetTypedNew(body, `type` field)** (csharp.rs +`extract_target_typed_new`): when the body IS an +`implicit_object_creation_expression` it emits ONE `instantiates` ref from +the property at the `new()`'s position, named by csharpClassTypeName +(csharp.rs `class_type_name`): `identifier` → its text; `generic_name` → its +identifier (`List` → `List`); `qualified_name` / `alias_qualified_name` +→ recurse into the `name` field (`Ns.Foo` → `Foo`, `global::Foo` → +`Foo`, `Outer.Inner` → `Inner`); `nullable_type` → recurse into `type` +(`Foo?` → `Foo`); anything else (predefined/array/tuple/pointer) → nothing. +Only a `new()` that IS the value counts — `(new())`, `c ? new() : null` and +nested `new()`s emit nothing. The fn-ref scan that follows skips the walked +bodies by node id, so a candidate is captured once — from the property; only +what is left (attribute arguments, e.g. `[Attr(Make(H))]` → `H` from the +class) is the class's. Attributes are never walked for calls. (The classifyMethodNode initializer-walk path at 1031-1047 is TS-only.) ### extractField (2046) — field_declaration @@ -342,6 +355,23 @@ classifyMethodNode initializer-walk path at 1031-1047 is TS-only.) the variable_declaration's `type` field (5905-5909) → **multi-declarator fields (`Foo A, B;`) emit the type refs ONCE PER DECLARATOR**, each from its own field node. +- Then, per created field node (changed 2026-10-05 — initializers used to + emit nothing but fn-ref candidates, attributed to the class): push the + field, **extractTargetTypedNew(the declarator's LAST named child, the + variable_declaration's `type` field)** (§property above — a target-typed + `private readonly List _items = new();` instantiates `List`), then + **visitFunctionBody(the whole declarator)**, pop. The C# declarator has no + `value` field — the initializer is its last, unnamed child (probed: + [name, expression]; a fixed buffer is [name, bracketed_argument_list]) — + so the declarator is walked whole, like VB.NET's: maybeCaptureFnRefs fires + on it first (varinit: `Del d = Handler;` → candidate `Handler` FROM THE + FIELD), then the name (inert) and the initializer — calls, instantiates, + static reads (`Defaults.Name` → `Defaults`) and lambda bodies (no halt: + `Func f = x => Compute(x)` → the field calls `Compute`) attribute + to the field. extractField returns the walked declarator ids and the + dispatcher's scanFnRefSubtree skips them, so each candidate is captured + once, by the field. (Java's walk of its `value` field reports nothing back + — its class-level scan still captures those candidates too, unchanged.) - docstring/visibility/isStatic computed once from the outer declaration, shared by all declarators. The PHP property_element and bare-fallback branches (2078-2154) are unreachable for C#. @@ -425,16 +455,20 @@ statements at top level) AND visitFunctionBody:5145. QUIRKS, PRESERVE: **`implicit_object_creation_expression` (`new()`) and `anonymous_object_creation_expression` (`new { X = 1 }`) and `array_creation_expression` (`new Widget[10]`) are NOT in INSTANTIATION_KINDS -→ no instantiates refs** (target-typed `new()` — everywhere in modern C# — is -invisible); object/collection initializer args and `new[] { Mk() }` contents -still recurse to their own calls. Top-level `var w = new Widget();` emits -nothing at all (§local_declaration_statement). +→ no instantiates refs** (a target-typed `new()` in a body — `Widget c = +new();`, an argument, a return — is invisible); the one exception is a +`new()` that IS a field's or property's initializer, which +extractTargetTypedNew names for the declared type (§property, §field). +Object/collection initializer args and `new[] { Mk() }` contents still +recurse to their own calls. Top-level `var w = new Widget();` emits nothing +at all (§local_declaration_statement). ### extractStaticMemberRef (4750) — csharp ∈ STATIC_MEMBER_LANGS (345) Called for EVERY node in visitFunctionBody (5218) — body walker only (never -visitNode, so class-level field initializers and top-level statements emit no -static refs). Node gate: MEMBER_ACCESS_TYPES (323) contains +visitNode, so top-level statements emit no static refs; field and property +initializers ARE body-walked, so `= Defaults.Name` reads attribute to the +member). Node gate: MEMBER_ACCESS_TYPES (323) contains `member_access_expression`. Skip when the access IS a call's callee (4772-4779: parent ∈ callTypes && callee.startIndex === node.startIndex — so `Console.WriteLine(…)`'s access is skipped but `DoThing(Constants.MAX)`'s @@ -621,14 +655,17 @@ layers: `argument`→null (descend named children). special: nothing (not idTypes/special). - Capture fires from visitNode:990, visitFunctionBody:5137, and scanFnRefSubtree (property/field/variable declarations + top-level - statements, depth ≤12, capture-only). **scanFnRefSubtree's halt list - (tree-sitter.ts:606-612) includes the literal type `lambda_expression` — - which IS C#'s lambda node** — so at depth>0 the scan STOPS at a lambda in a - field/property initializer (`Action A = () => Register(H);` yields no - candidates from inside the lambda), while `anonymous_method_expression` - (`delegate() { … }`) is NOT in the list and is scanned through. Method-BODY - lambdas are unaffected (visitFunctionBody recursion has no halt — capture - fires per node). + statements, depth ≤12, capture-only — skipping the subtrees the body + walker already went through: property bodies/initializers and field + declarators, whose candidates are the member's). **scanFnRefSubtree's halt + list (tree-sitter.ts:606-612) includes the literal type `lambda_expression` + — which IS C#'s lambda node** — so at depth>0 the scan STOPS at a lambda it + reaches (a top-level statement's), while `anonymous_method_expression` + (`delegate() { … }`) is NOT in the list and is scanned through. Body-walked + lambdas — methods, accessors, and since 2026-10-05 field/property + initializers (`Action A = () => Register(H);` → candidate `H` from `A`) — + are unaffected (visitFunctionBody recursion has no halt — capture fires per + node). - Flush gate (flushFnRefCandidates:639): generated-file skip; `this.`-prefixed names skip the gate (C# never produces them — its this-forms are bare); otherwise name ∈ definedHere ∪ importedNames. **definedHere = same-file @@ -762,10 +799,14 @@ AspNetCore refs / Program.cs / Startup.cs / controller-source scan. `struct Fwd;` (NO node); interface with bodyless method + property + default-impl arrow method; enum with `: byte` (extends `byte` quirk), attributed member, valued members; const + static-readonly (→ `constant`) - + multi-declarator + instance fields (signatures `Type name`); + + multi-declarator + instance fields (signatures `Type name`), field + initializers (→ the field: `= new() { TargetCb }` instantiates the declared + `List` and the list candidate is the field's); `protected internal` (→ protected); property shapes: predefined-type, bare-identifier type (signature loses type), generic type, expression-bodied - (`=>` calls → the property), `{ get; } = new();` (initializer LOST), + (`=>` calls → the property), `{ get; } = new();` (→ the property, + instantiates the declared `Widget`), `{ get; } = () => Register(H)` (the + lambda's call and candidate → the property), accessor bodies with calls (→ the property); event_field_declaration + event_declaration with add/remove bodies (no nodes; accessor calls → class); operator + conversion operator + indexer + @@ -779,7 +820,7 @@ AspNetCore refs / Program.cs / Startup.cs / controller-source scan. `Foo.Create(1).Bar()` (re-encode + inner both) + `GetThing().Bar()`, `(myDel)(x)` (conv regex → `myDel`), `nameof(Widget)` (calls ref `nameof`); `new Widget(…) { … }` (instantiates + initializer calls) + `new Ns.Foo()` - (strip both) + `new()` / `new { }` / `new Widget[10]` (all NOTHING); + (strip both) + body `new()` / `new { }` / `new Widget[10]` (all NOTHING); static value reads (`ReadType.ReadAsDouble` → `ReadType`; `Outer.Inner.DEEP` → `Outer`; skip-as-callee; lowercase skip); type refs: params (nullable/array/tuple-element/generic/qualified/`dynamic`), returns @@ -792,7 +833,8 @@ AspNetCore refs / Program.cs / Startup.cs / controller-source scan. function + trailing `partial class Program`; fn-refs: `Register(HandleThing)`, `Register(this.HandleThing)` (bare name), `Register(C.StaticHandler)` (nothing), `Click += OnClick`, initializer_expression list, varinit - (`Action g = () => …` no candidate; `Del d = Handler;` candidate), + (`Action g = () => …` no candidate; `Del d = Handler;` candidate — from + the method for a local, from the field for a field initializer), `this.x = x` param-storage skip; value-refs: const target + reader methods + a `var MaxItems = …` local shadow (prune) + `static readonly` multi-target; preprocessor: `#region`/`#endregion`/`#pragma`/`#nullable`/`#define` diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 9a1086247..222291f14 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -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` → `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 table = List.of(Main::cb)`, - // C# `List> table = new() { TargetCb }`. - this.scanFnRefSubtree(node, 0); + const walked = this.extractField(node); + // Scan the declaration for function-as-value candidates (#756): Java + // `List table = List.of(Main::cb)`. A C# declarator + // extractField walked captured its own (`List> 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 _items = new();` instantiates List, as + * `new List()` 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 { + const walked = new Set(); + 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; } /** diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 2984a4346..f721a2cc4 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -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'); } /**