Files
codegraph/__tests__/csharp-member-initializers.test.ts
T
Colby Mchenry 355afc70b1 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>
2026-10-05 17:20:15 +00:00

156 lines
6.3 KiB
TypeScript

/**
* C# field and property initializers belong to the member they initialize.
* `private readonly ILogger _log = LogManager.GetLogger(typeof(X));` and
* `public List<Foo> Items { get; } = new List<Foo>();` 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<string, string> = {
'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<T> { }
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<Widget> _bag = new();
private readonly Lib.Crate _crate = new();
private readonly string _label = Defaults.Name;
private readonly System.Func<int, int> _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<Widget> Items { get; } = new Bag<Widget>();
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([]);
});
});