From 9f415ccbe28a7ab562364439b21090d7e93bf12b Mon Sep 17 00:00:00 2001 From: Tirth Kanani Date: Sun, 14 Jun 2026 18:05:25 +0100 Subject: [PATCH 1/2] fix(csharp-extractor): capture aliased usings and qualified `new` expressions in the call graph Two C# extractor edge cases dropped real declarations: 1. Aliased using directives whose target is a single undotted name (`using Alias = System;`) emit the target as a plain `identifier`, not a `qualified_name`. The alias branch only looked for `qualified_name` and returned null, dropping the whole import. Now it falls back to the identifier that follows the `=` token (not the first identifier, which is the alias name itself). 2. `object_creation_expression` for a qualified type (`new System.Text.StringBuilder()`) places the type as a `qualified_name` child, which neither the `identifier` nor `generic_name` lookup matched, so the object creation was dropped from the call graph. Added `qualified_name` to the fallback chain. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/csharp-extractor.test.ts | 28 +++++++++++++++++++ .../plugins/extractors/csharp-extractor.ts | 23 +++++++++++++-- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/csharp-extractor.test.ts b/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/csharp-extractor.test.ts index e1534b8d1..b67b90834 100644 --- a/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/csharp-extractor.test.ts +++ b/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/csharp-extractor.test.ts @@ -302,6 +302,20 @@ namespace App { tree.delete(); parser.delete(); }); + + it("extracts aliased using with a simple-identifier target", () => { + const { tree, parser, root } = parse(`using Alias = System; +namespace App { public class C {} } +`); + const result = extractor.extractStructure(root); + + expect(result.imports).toEqual([ + { source: "System", specifiers: ["System"], lineNumber: 1 }, + ]); + + tree.delete(); + parser.delete(); + }); }); // ---- Exports ---- @@ -452,6 +466,20 @@ namespace App { parser.delete(); }); + it("extracts object creation of a qualified type", () => { + const { tree, parser, root } = parse(`namespace App { public class C { public void M() { var x = new System.Text.StringBuilder(); } } }`); + const result = extractor.extractCallGraph(root); + + expect(result).toContainEqual({ + caller: "M", + callee: "new System.Text.StringBuilder", + lineNumber: 1, + }); + + tree.delete(); + parser.delete(); + }); + it("tracks correct caller for constructors", () => { const { tree, parser, root } = parse(`namespace App { public class Foo { diff --git a/understand-anything-plugin/packages/core/src/plugins/extractors/csharp-extractor.ts b/understand-anything-plugin/packages/core/src/plugins/extractors/csharp-extractor.ts index 19b77b5b9..403679e6a 100644 --- a/understand-anything-plugin/packages/core/src/plugins/extractors/csharp-extractor.ts +++ b/understand-anything-plugin/packages/core/src/plugins/extractors/csharp-extractor.ts @@ -68,7 +68,20 @@ function extractUsingSource(node: TreeSitterNode): string | null { if (hasEquals) { // The target namespace is the qualified_name after the `=` const qualifiedName = findChild(node, "qualified_name"); - return qualifiedName ? qualifiedName.text : null; + if (qualifiedName) return qualifiedName.text; + // Simple-identifier target (`using Alias = System;`): the target is the + // identifier AFTER the `=`, not the first identifier (which is the alias). + let seenEquals = false; + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (!child) continue; + if (child.type === "=") { + seenEquals = true; + continue; + } + if (seenEquals && child.type === "identifier") return child.text; + } + return null; } // Simple or qualified using @@ -173,8 +186,12 @@ export class CSharpExtractor implements LanguageExtractor { // Extract object creation: e.g. new Foo() if (node.type === "object_creation_expression") { if (functionStack.length > 0) { - // The type is the child after `new` — can be identifier or generic_name - const typeNode = findChild(node, "identifier") ?? findChild(node, "generic_name"); + // The type is the child after `new` — can be identifier, generic_name, + // or a qualified_name (e.g. `new System.Text.StringBuilder()`). + const typeNode = + findChild(node, "identifier") ?? + findChild(node, "generic_name") ?? + findChild(node, "qualified_name"); if (typeNode) { entries.push({ caller: functionStack[functionStack.length - 1], From 3363982f1afc7ea9f31d3c9f32d8656cfd7e34b2 Mon Sep 17 00:00:00 2001 From: Tirth Kanani Date: Tue, 16 Jun 2026 23:31:38 +0100 Subject: [PATCH 2/2] test(csharp-extractor): lock generic/global-using behavior; single-pass using parse Address PR review on the C# extractor edge cases: - Add call-graph tests for `new List()` (generic_name) and `new System.Collections.Generic.List()` (qualified_name wrapping a trailing generic_name). The existing fallback chain already produces the correct callee text; the tests lock it. - Add structure tests for `global using` forms (plain, simple-identifier alias, qualified alias). tree-sitter-c-sharp parses these as a `using_directive` with a leading `global` child, so the existing handler already covers them; document this in the `walkTopLevel` switch. - Consolidate `extractUsingSource` into a single pass over the directive's children instead of a `findChild(=)` scan followed by a second seenEquals scan, removing the duplicate traversal. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/csharp-extractor.test.ts | 84 +++++++++++++++++++ .../plugins/extractors/csharp-extractor.ts | 66 +++++++++------ 2 files changed, 124 insertions(+), 26 deletions(-) diff --git a/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/csharp-extractor.test.ts b/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/csharp-extractor.test.ts index b67b90834..2b3a4bb1a 100644 --- a/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/csharp-extractor.test.ts +++ b/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/csharp-extractor.test.ts @@ -316,6 +316,58 @@ namespace App { public class C {} } tree.delete(); parser.delete(); }); + + it("extracts a global using directive", () => { + const { tree, parser, root } = parse(`global using System.Collections.Generic; +namespace App { public class C {} } +`); + const result = extractor.extractStructure(root); + + // tree-sitter-c-sharp parses `global using` as a `using_directive` + // carrying a `global` modifier child, so the existing handler applies. + expect(result.imports).toEqual([ + { + source: "System.Collections.Generic", + specifiers: ["Generic"], + lineNumber: 1, + }, + ]); + + tree.delete(); + parser.delete(); + }); + + it("extracts a global using with a simple-identifier alias target", () => { + const { tree, parser, root } = parse(`global using Alias = System; +namespace App { public class C {} } +`); + const result = extractor.extractStructure(root); + + expect(result.imports).toEqual([ + { source: "System", specifiers: ["System"], lineNumber: 1 }, + ]); + + tree.delete(); + parser.delete(); + }); + + it("extracts a global using with a qualified alias target", () => { + const { tree, parser, root } = parse(`global using Alias = System.Collections.Generic; +namespace App { public class C {} } +`); + const result = extractor.extractStructure(root); + + expect(result.imports).toEqual([ + { + source: "System.Collections.Generic", + specifiers: ["Generic"], + lineNumber: 1, + }, + ]); + + tree.delete(); + parser.delete(); + }); }); // ---- Exports ---- @@ -480,6 +532,38 @@ namespace App { public class C {} } parser.delete(); }); + it("extracts object creation of a generic type", () => { + const { tree, parser, root } = parse(`namespace App { public class C { public void M() { var x = new List(); } } }`); + const result = extractor.extractCallGraph(root); + + // The type node is a `generic_name`; its text includes the type arguments. + expect(result).toContainEqual({ + caller: "M", + callee: "new List", + lineNumber: 1, + }); + + tree.delete(); + parser.delete(); + }); + + it("extracts object creation of a qualified generic type", () => { + const { tree, parser, root } = parse(`namespace App { public class C { public void M() { var x = new System.Collections.Generic.List(); } } }`); + const result = extractor.extractCallGraph(root); + + // tree-sitter-c-sharp shapes this as a single `qualified_name` whose + // trailing segment is a `generic_name`, so the node text carries the + // full dotted path including the type arguments. + expect(result).toContainEqual({ + caller: "M", + callee: "new System.Collections.Generic.List", + lineNumber: 1, + }); + + tree.delete(); + parser.delete(); + }); + it("tracks correct caller for constructors", () => { const { tree, parser, root } = parse(`namespace App { public class Foo { diff --git a/understand-anything-plugin/packages/core/src/plugins/extractors/csharp-extractor.ts b/understand-anything-plugin/packages/core/src/plugins/extractors/csharp-extractor.ts index 403679e6a..85e6d45c0 100644 --- a/understand-anything-plugin/packages/core/src/plugins/extractors/csharp-extractor.ts +++ b/understand-anything-plugin/packages/core/src/plugins/extractors/csharp-extractor.ts @@ -59,37 +59,47 @@ function hasModifier(node: TreeSitterNode, modifier: string): boolean { * * Handles both simple identifiers (`using System;`) and qualified names * (`using System.Collections.Generic;`). For aliased usings like - * `using Alias = Some.Namespace;`, extracts the target namespace. + * `using Alias = Some.Namespace;`, extracts the target namespace (the part + * after the `=`, not the alias). `global using` forms parse as a + * `using_directive` with a leading `global` child, so they flow through the + * same single pass below. */ function extractUsingSource(node: TreeSitterNode): string | null { - // Check for alias form: `using Alias = Some.Namespace;` - const hasEquals = findChild(node, "=") !== null; - - if (hasEquals) { - // The target namespace is the qualified_name after the `=` - const qualifiedName = findChild(node, "qualified_name"); - if (qualifiedName) return qualifiedName.text; - // Simple-identifier target (`using Alias = System;`): the target is the - // identifier AFTER the `=`, not the first identifier (which is the alias). - let seenEquals = false; - for (let i = 0; i < node.childCount; i++) { - const child = node.child(i); - if (!child) continue; - if (child.type === "=") { - seenEquals = true; - continue; - } - if (seenEquals && child.type === "identifier") return child.text; + // A single pass over the children covers every using shape: + // `using System;` -> first identifier + // `using System.Collections.Generic;` -> qualified_name + // `using Alias = System;` -> identifier AFTER the `=` + // `using Alias = System.Collections.X;` -> qualified_name AFTER the `=` + // The alias name (before `=`) is intentionally skipped; `source` is always + // the target namespace. + let target: string | null = null; + + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (!child) continue; + + if (child.type === "=") { + // Discard anything seen before the `=` (that was the alias name) and + // resume scanning for the real target after it. + target = null; + continue; } - return null; - } - // Simple or qualified using - const qualifiedName = findChild(node, "qualified_name"); - if (qualifiedName) return qualifiedName.text; + if (child.type === "qualified_name") { + // qualified_name is unambiguous: take it immediately whether or not we + // are in an alias directive. + return child.text; + } + + if (child.type === "identifier" && target === null) { + // First identifier (the post-`=` one in the alias case). For a simple + // target this is the answer; we keep scanning in case a qualified_name + // follows (it would win above). + target = child.text; + } + } - const identifier = findChild(node, "identifier"); - return identifier ? identifier.text : null; + return target; } /** @@ -236,6 +246,10 @@ export class CSharpExtractor implements LanguageExtractor { switch (child.type) { case "using_directive": + // Covers `using X;`, `using X.Y;`, `using A = X;`, and the C# 10 + // `global using ...` forms — tree-sitter-c-sharp parses the latter + // as a `using_directive` with a leading `global` modifier child + // rather than a distinct node type. this.extractUsing(child, imports); break;