Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Foo> 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)
Expand Down
155 changes: 155 additions & 0 deletions __tests__/csharp-member-initializers.test.ts
Original file line number Diff line number Diff line change
@@ -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<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([]);
});
});
42 changes: 42 additions & 0 deletions __tests__/csharp-type-position-refs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
'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();
}
});
});
40 changes: 33 additions & 7 deletions __tests__/kernel-csharp-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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) { }',
Expand All @@ -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<Action> Table = new() { H };',
' private Widget? _w = new(1) { Size = Make() };',
' private global::Ns.Box<int> _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) {
Expand Down
Loading