diff --git a/.mex/patterns/INDEX.md b/.mex/patterns/INDEX.md index ca0eeb5f..d6b3049f 100644 --- a/.mex/patterns/INDEX.md +++ b/.mex/patterns/INDEX.md @@ -1,6 +1,13 @@ +--- +edges: + - target: syntax-extractor-review.md + condition: when adding or reviewing syntax-only language extractors +last_updated: 2026-09-10 +--- + # Pattern Index -Lookup table for project-specific pattern files. No project patterns are currently registered. +Lookup table for project-specific pattern files. | Pattern | Use when | |---------|----------| diff --git a/.mex/patterns/syntax-extractor-review.md b/.mex/patterns/syntax-extractor-review.md new file mode 100644 index 00000000..30375f24 --- /dev/null +++ b/.mex/patterns/syntax-extractor-review.md @@ -0,0 +1,72 @@ +--- +name: syntax-extractor-review +description: Verify declarations and references against the vendored grammar and persisted graph, including deliberate resolution gaps. +triggers: + - language extractor + - C# extraction + - tree-sitter grammar +edges: + - target: context/conventions.md + condition: when verifying extractor or resolver changes +grounds_to: [] +last_updated: 2026-09-10 +--- + +# Syntax extractor review + +## Context + +Read `docs/extractors.md` and the language entry in `docs/code-graph-support.md`. +The vendored WASM is the runtime contract; a successful parse alone does not +prove that declarations, ownership, or references are correct. + +## Steps + +1. Inspect actual grammar fields using the vendored binary before choosing + declaration names or traversal boundaries. +2. Assert qualified names, containment, and reference owners. Reorder distinct + declarations to check that their identities follow the symbols. +3. Preserve receivers in unresolved call names. Normalize member separators + through grammar fields so comments and spacing do not change binding. +4. Verify both extracted references and persisted graph edges. A same-named + lexical method cannot prove the target of an arbitrary object receiver. +5. Document remaining syntax and resolution limitations alongside the tests. + +## Gotchas + +- C# file-scoped namespace declarations own later root siblings; walking them + again creates duplicate symbols outside the namespace. +- Operators need their tokens, conversions their target types, and destructors + their `~` prefix. Static constructors use `static C` so reordering them with + instance constructors cannot swap identities. Indexers have no name field and + need bracketed signatures. +- Walk each field declarator's initializer under that field's ownership. +- Enum attributes precede identifiers; use the grammar name field. +- Interface bases are `extends`. The class base-list split remains heuristic. +- C# `this.M()` cannot bind to a local function named `M`. Unproven object, + `base`, namespace, and alias qualifiers remain unresolved until semantic + binding exists, including on inheritance and construction references. +- Keep the caller among C# call candidates: deleting it from an overload set + can turn a recursive call into a confident edge to the wrong overload. Only + an unambiguous lexical recursive target can produce a self edge. + +## Verify + +- Run `extractor-csharp.test.ts` and `engine-csharp.test.ts` for the concrete + declaration, identity, ownership, and conservative-resolution regressions. +- Run shared graph regressions, typecheck, build, and evaluator checks. Confirm + the packaged grammar matches its vendored source bytes. + +## Debug + +Trace a failing call through its extracted `targetName`, persisted receiver, +candidate containers, and final edge. Keep unresolved evidence when the target +cannot be proven; do not strip a receiver to force a match. + +## Update Scaffold + +Recorded during the 2026-09-10 PR #156 fixes. Source paths and tests above are +the evidence; graph anchors are intentionally absent because the available +checkout index belonged to another branch. Refresh only through explicit graph +maintenance before adding fingerprints. Update this pattern when another +grammar-specific traversal or binding failure is reproduced. diff --git a/docs/code-graph-support.md b/docs/code-graph-support.md index a98c8b94..16b29fa1 100644 --- a/docs/code-graph-support.md +++ b/docs/code-graph-support.md @@ -26,6 +26,7 @@ extractor registry lives in | **Supported** | JSX | `.jsx` | [`jsx-component.jsx`](../src/graph/__tests__/fixtures/jsx-component.jsx) and [`extraction-regression.test.ts`](../src/graph/__tests__/extraction-regression.test.ts) cover components, imports, calls, and construction. | | **Supported** | Python | `.py` | [`sample.py`](../src/graph/__tests__/fixtures/sample.py), [`extractor-python.test.ts`](../src/graph/__tests__/extractor-python.test.ts), and the [`python-package`](../src/graph/__tests__/fixtures/python-package) integration fixture cover extraction and cross-file package resolution. | | **Supported** | Rust | `.rs` | [`sample.rs`](../src/graph/__tests__/fixtures/sample.rs) and [`extractor-rust.test.ts`](../src/graph/__tests__/extractor-rust.test.ts) cover structs, traits, enums, modules, functions, methods, generics, imports, calls, implementations, construction, returns, and field types. | +| **Partial** | C# | `.cs` | [`sample.cs`](../src/graph/__tests__/fixtures/sample.cs) and [`extractor-csharp.test.ts`](../src/graph/__tests__/extractor-csharp.test.ts) cover namespaces (including nested/file-scoped), classes, interfaces, structs, enums, properties, overloaded indexers, field initializers, `const` fields, constructors/destructors, operators/conversions, static methods, parameters, attributes, `using` imports, calls with receivers, instantiation, and base-list extends/implements. [`engine-csharp.test.ts`](../src/graph/__tests__/engine-csharp.test.ts) verifies persistence and conservative call resolution. Ran clean (0 partial/failed) across 694 real-world `.cs` files in one large external repository. Marked partial, not supported: the `extends`/`implements` split on a class's base list is a first-listed-entry heuristic, not a semantic resolution (documented in `csharp.ts`), generics/type-parameter capture (`typeParameters`, matching Rust's `.rs` support) is not yet implemented, and call binding is limited to proven lexical scope. Calls through arbitrary objects or `base`, and qualified type references (including inheritance and construction), stay unresolved without semantic binding evidence. Recursive calls with multiple same-named overloads also remain unresolved; a unique lexical recursive call can bind to itself. Static constructors have distinct `static C` names so their identities survive reordering against instance constructors. | | **Unsupported** | Go and other languages | All other extensions | These names may be reserved in [`src/graph/types.ts`](../src/graph/types.ts), but no grammar or extractor is registered for them. Unsupported files are skipped rather than failing a graph build. | `src/graph/types.ts` contains a wider future-facing language vocabulary. A name diff --git a/docs/extractors.md b/docs/extractors.md index b9cbfefb..43ef468e 100644 --- a/docs/extractors.md +++ b/docs/extractors.md @@ -75,6 +75,9 @@ The build copies every vendored `.wasm` file into `dist/wasm/`. A new grammar mu - Emit resolved `contains` edges when both endpoints are known in the file. - Leave cross-file targets unresolved with `targetName` and optional candidates. - Include signatures, documentation, visibility, export state, and type information when the grammar exposes them reliably. +- Preserve call receivers in `targetName`; a same-named method in lexical scope does not prove the target of `other.Method()`. Test unresolved persistence as well as extraction. +- Use grammar name/operator/type fields for declaration names. Include operator tokens, conversion target types, and indexer signatures so reordering does not swap identities. +- Verify traversal ownership: file-scoped namespace siblings must be visited once, and field initializers/indexer bodies must retain their declaring symbol as the reference source. - Prefer stable semantic assertions over exact node/edge counts that make fixtures hard to extend. Use [`src/graph/__tests__/extractor.test.ts`](../src/graph/__tests__/extractor.test.ts) and its `sample.ts` fixture as the test pattern. Cover at least: @@ -115,6 +118,15 @@ When adding a language, document the grammar source and version. - **Upstream grammar:** [tree-sitter/tree-sitter-rust](https://github.com/tree-sitter/tree-sitter-rust) - **Upstream grammar license:** MIT +### C# +- **Binary source:** `tree-sitter-c-sharp` package, version `0.23.5` (the grammar's own published package, not `tree-sitter-wasms` — see note below) +- **Vendored path:** `src/graph/wasm/tree-sitter-c-sharp.wasm` +- **SHA-256:** matches `node_modules/tree-sitter-c-sharp@0.23.5/tree-sitter-c_sharp.wasm` exactly +- **Binary package license:** MIT +- **Upstream grammar:** [tree-sitter/tree-sitter-c-sharp](https://github.com/tree-sitter/tree-sitter-c-sharp) +- **Upstream grammar license:** MIT +- **Note:** `tree-sitter-wasms@0.1.12` also ships a `tree-sitter-c_sharp.wasm`, but it is built from a different grammar revision than the `node-types.json` published with `tree-sitter-c-sharp@0.23.5`. Several fields that `node-types.json` declares (`variable_declarator.name`, `using_directive.name`) return `undefined` via `childForFieldName` against that older build. The extractor is written and tested against 0.23.5's own field layout — use the grammar's own published wasm for this language, not `tree-sitter-wasms`'s copy. + ## Pull request proof Before opening a pull request, run: diff --git a/src/graph/__tests__/engine-csharp.test.ts b/src/graph/__tests__/engine-csharp.test.ts new file mode 100644 index 00000000..eb5e5f11 --- /dev/null +++ b/src/graph/__tests__/engine-csharp.test.ts @@ -0,0 +1,205 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { openSqlite } from "../db/sqlite.js"; +import { createGraphEngine } from "../engine-impl.js"; +import type { GraphEngine } from "../engine.js"; + +let root: string; +let engine: GraphEngine; + +beforeAll(async () => { + root = mkdtempSync(join(tmpdir(), "mex-csharp-graph-")); + writeFileSync(join(root, "sample.cs"), ` +namespace Example; +class A : B { + void Start(B other) { other.Run(); base.Run(); StaticB.Run(); GetOther().Run(); } + B GetOther() => new B(); + void Local() { Run(); this.Run(); this . Run(); this /* comment */.Run(); } + void Run() {} + static int Init() => 1; + int value = Init(); + public int this[int index] => Init(); +} +class B { public void Run() {} } +class StaticB { public static void Run() {} } +class C : B { void LocalShadow() { void Run() {} this.Run(); } } +interface IRoot {} +interface IChild : IRoot {} +`); + writeFileSync(join(root, "qualified-types.cs"), ` +class Resource {} +namespace Collisions { + class Base {} + interface IContract {} + class QualifiedChild : Elsewhere.Base {} + class GlobalChild : global::Elsewhere.Base {} + class QualifiedImplements : Base, Elsewhere.IContract {} + class GlobalImplements : Base, global::Elsewhere.IContract {} + class LocalChild : Base, IContract {} + class Factory { + class Resource {} + object QualifiedCreate() => new Elsewhere.Resource(); + object GlobalCreate() => new global::Elsewhere.Resource(); + object GlobalRootCreate() => new global::Resource(); + object LocalCreate() => new Resource(); + } +} +namespace Elsewhere { + class Base {} + interface IContract {} + class Resource {} +} +`); + writeFileSync(join(root, "recursion.cs"), ` +namespace Recursion; +class Overloaded { + void Run() { Run(); this.Run(); } + void Run(int count) {} +} +class Unique { + void Repeat() { Repeat(); this.Repeat(); } +} +`); + engine = createGraphEngine({ rootDir: root }); + await engine.build(root); +}); + +afterAll(() => { + engine?.close(); + if (root) rmSync(root, { recursive: true, force: true }); +}); + +function symbol(qualifiedName: string, signature?: string) { + const matches = engine.searchNodes(qualifiedName).filter((node) => + node.qualifiedName === qualifiedName && (signature === undefined || node.signature === signature), + ); + expect(matches).toHaveLength(1); + return matches[0]!; +} + +describe("C# graph persistence and resolution", () => { + it("indexes file-scoped declarations once with their namespace", () => { + expect(engine.searchNodes("Run").filter((node) => node.name === "Run" && node.filePath === "sample.cs") + .map((node) => node.qualifiedName).sort()) + .toEqual(["Example.A.Run", "Example.B.Run", "Example.C.LocalShadow.Run", "Example.StaticB.Run"]); + }); + + it("keeps unproven receivers unresolved despite same-named lexical methods", () => { + const caller = symbol("Example.A.Start"); + expect(engine.getCallees(caller.id).map((node) => node.qualifiedName)) + .toEqual(["Example.A.GetOther"]); + const db = openSqlite(join(root, ".mex", "graph.db")); + try { + expect(db.prepare(` + SELECT reference_name, receiver, status, target_id, confidence + FROM unresolved_refs WHERE from_node_id = ? AND reference_name LIKE '%.Run' + ORDER BY reference_name + `).all(caller.id)).toEqual([ + { reference_name: "GetOther().Run", receiver: "GetOther()", status: "unresolved", target_id: null, confidence: 0 }, + { reference_name: "StaticB.Run", receiver: "StaticB", status: "unresolved", target_id: null, confidence: 0 }, + { reference_name: "base.Run", receiver: "base", status: "unresolved", target_id: null, confidence: 0 }, + { reference_name: "other.Run", receiver: "other", status: "unresolved", target_id: null, confidence: 0 }, + ]); + } finally { + db.close(); + } + }); + + it("still resolves unqualified and this calls in the lexical type", () => { + const target = symbol("Example.A.Run"); + const calls = engine.getOutgoing(symbol("Example.A.Local").id, ["calls"]); + expect(calls).toHaveLength(4); + for (const call of calls) { + expect(call.node.id).toBe(target.id); + expect(call.edge).toMatchObject({ resolutionMethod: "lexical-scope", confidence: 1 }); + } + }); + + it("does not bind an explicit this receiver to a shadowing local function", () => { + expect(engine.getCallees(symbol("Example.C.LocalShadow").id)).toEqual([]); + }); + + it("resolves field initializer and indexer calls from their owning symbols", () => { + for (const owner of ["Example.A.value", "Example.A.this"]) { + expect(engine.getCallees(symbol(owner).id).map((node) => node.qualifiedName)) + .toEqual(["Example.A.Init"]); + } + }); + + it("persists interface inheritance as extends", () => { + const child = symbol("Example.IChild"); + expect(engine.getOutgoing(child.id, ["extends"]).map((neighbor) => neighbor.node.id)) + .toEqual([symbol("Example.IRoot").id]); + expect(engine.getOutgoing(child.id, ["implements"])).toEqual([]); + }); + + it("retains qualified type references without binding same-named lexical types", () => { + const db = openSqlite(join(root, ".mex", "graph.db")); + try { + for (const [owner, kind, reference] of [ + ["Collisions.QualifiedChild", "extends", "Elsewhere.Base"], + ["Collisions.GlobalChild", "extends", "global::Elsewhere.Base"], + ["Collisions.QualifiedImplements", "implements", "Elsewhere.IContract"], + ["Collisions.GlobalImplements", "implements", "global::Elsewhere.IContract"], + ["Collisions.Factory.QualifiedCreate", "instantiates", "Elsewhere.Resource"], + ["Collisions.Factory.GlobalCreate", "instantiates", "global::Elsewhere.Resource"], + ["Collisions.Factory.GlobalRootCreate", "instantiates", "global::Resource"], + ] as const) { + const source = symbol(owner); + expect(engine.getOutgoing(source.id, [kind])).toEqual([]); + expect(db.prepare(` + SELECT reference_name, reference_kind, status, target_id, confidence + FROM unresolved_refs WHERE from_node_id = ? AND reference_name = ? + `).all(source.id, reference)).toEqual([ + { reference_name: reference, reference_kind: kind, status: "unresolved", target_id: null, confidence: 0 }, + ]); + } + } finally { + db.close(); + } + }); + + it("still resolves unqualified inheritance and construction in lexical scope", () => { + const child = symbol("Collisions.LocalChild"); + expect(engine.getOutgoing(child.id, ["extends"]).map((neighbor) => neighbor.node.id)) + .toEqual([symbol("Collisions.Base").id]); + expect(engine.getOutgoing(child.id, ["implements"]).map((neighbor) => neighbor.node.id)) + .toEqual([symbol("Collisions.IContract").id]); + expect(engine.getOutgoing(symbol("Collisions.Factory.LocalCreate").id, ["instantiates"]) + .map((neighbor) => neighbor.node.id)) + .toEqual([symbol("Collisions.Factory.Resource").id]); + }); + + it("does not discard the recursive overload before deciding whether a call is ambiguous", () => { + const caller = symbol("Recursion.Overloaded.Run", "()"); + expect(engine.getCallees(caller.id)).toEqual([]); + const db = openSqlite(join(root, ".mex", "graph.db")); + try { + const references = db.prepare(` + SELECT reference_name, status, target_id, confidence + FROM unresolved_refs WHERE from_node_id = ? AND reference_kind = 'calls' + ORDER BY reference_name + `).all(caller.id) as Array<{ reference_name: string; status: string; target_id: string | null; confidence: number }>; + expect(references.map((reference) => reference.reference_name)).toEqual(["Run", "this.Run"]); + for (const reference of references) { + expect(["unresolved", "ambiguous"]).toContain(reference.status); + expect(reference.target_id).toBeNull(); + expect(reference.confidence).toBeLessThan(1); + } + } finally { + db.close(); + } + }); + + it("resolves unique recursive calls to their own method, including explicit this calls", () => { + const caller = symbol("Recursion.Unique.Repeat"); + const calls = engine.getOutgoing(caller.id, ["calls"]); + expect(calls).toHaveLength(2); + for (const call of calls) { + expect(call.node.id).toBe(caller.id); + expect(call.edge).toMatchObject({ resolutionMethod: "lexical-scope", confidence: 1 }); + } + }); +}); diff --git a/src/graph/__tests__/extractor-csharp.test.ts b/src/graph/__tests__/extractor-csharp.test.ts new file mode 100644 index 00000000..d7bbc7ed --- /dev/null +++ b/src/graph/__tests__/extractor-csharp.test.ts @@ -0,0 +1,327 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { beforeAll, describe, expect, it } from "vitest"; +import { extractFile, loadGrammars } from "../extraction/index.js"; +import type { FileExtraction } from "../extraction/index.js"; + +const FIXTURE = join(dirname(fileURLToPath(import.meta.url)), "fixtures", "sample.cs"); + +describe("C# extractor", () => { + let result: FileExtraction; + + beforeAll(async () => { + await loadGrammars(["csharp"]); + const source = readFileSync(FIXTURE, "utf-8"); + result = extractFile("fixtures/sample.cs", source, "csharp")!; + expect(result).not.toBeNull(); + }); + + const node = (kind: string, name: string) => + result.nodes.find((n) => n.kind === kind && n.name === name); + const hasEdge = (kind: string, targetName: string) => + result.edges.some((e) => e.kind === kind && e.targetName === targetName); + const extractSource = (source: string) => { + const extracted = extractFile("regressions.cs", source, "csharp")!; + expect(extracted).not.toBeNull(); + expect(extracted.health.status).toBe("ok"); + return extracted; + }; + + it("emits a file node and stamps the language", () => { + expect(result.language).toBe("csharp"); + expect(node("file", "sample.cs")).toBeDefined(); + }); + + it("extracts namespaces, including nested ones", () => { + expect(node("namespace", "MyApp.Models")).toBeDefined(); + const auditLog = node("class", "AuditLog"); + expect(auditLog).toBeDefined(); + expect(auditLog!.qualifiedName).toBe("MyApp.Models.Admin.AuditLog"); + }); + + it("extracts interfaces, structs, and enums", () => { + expect(node("interface", "IGreeter")).toBeDefined(); + + const point = node("struct", "Point"); + expect(point).toBeDefined(); + expect(node("field", "X")).toBeDefined(); + expect(node("field", "Y")).toBeDefined(); + + const role = node("enum", "Role"); + expect(role).toBeDefined(); + expect(node("enum_member", "Admin")).toBeDefined(); + expect(node("enum_member", "Member")).toBeDefined(); + }); + + it("extracts a class, its visibility, and its members", () => { + const user = node("class", "User"); + expect(user).toBeDefined(); + expect(user!.visibility).toBe("public"); + expect(user!.isExported).toBe(true); + + const nameProp = node("property", "Name"); + expect(nameProp).toBeDefined(); + expect(nameProp!.returnType).toBe("string"); + + expect(node("field", "name")).toBeDefined(); + + const maxAge = node("constant", "MaxAge"); + expect(maxAge).toBeDefined(); + expect(maxAge!.isStatic).toBe(true); + }); + + it("extracts a constructor named after the class", () => { + const ctor = result.nodes.find( + (n) => n.kind === "method" && n.qualifiedName === "MyApp.Models.User.User", + ); + expect(ctor).toBeDefined(); + }); + + it("extracts methods, including static ones, with parameters", () => { + // "Greet" is declared on both IGreeter and User — disambiguate by qualifiedName. + const greet = result.nodes.find( + (n) => n.kind === "method" && n.qualifiedName === "MyApp.Models.User.Greet", + ); + expect(greet).toBeDefined(); + + const create = node("method", "Create"); + expect(create).toBeDefined(); + expect(create!.isStatic).toBe(true); + + const param = result.nodes.find( + (n) => n.kind === "parameter" && n.qualifiedName === "MyApp.Models.User.Create.name", + ); + expect(param).toBeDefined(); + }); + + it("emits extends/implements from the base list", () => { + expect(hasEdge("extends", "BaseEntity")).toBe(true); + expect(hasEdge("implements", "IGreeter")).toBe(true); + }); + + it("emits an attribute as a decorates edge", () => { + expect(hasEdge("decorates", "Serializable")).toBe(true); + }); + + it("emits import edges for using directives", () => { + expect(hasEdge("imports", "System")).toBe(true); + expect(hasEdge("imports", "System.Collections.Generic")).toBe(true); + }); + + it("emits calls and instantiates references", () => { + expect(hasEdge("instantiates", "User")).toBe(true); + expect(hasEdge("calls", "Logger.Log")).toBe(true); + expect(hasEdge("calls", "user.Greet")).toBe(true); + expect(hasEdge("calls", "Console.WriteLine")).toBe(true); + }); + + it("nests methods under their class via contains edges", () => { + const userClass = node("class", "User")!; + const greet = result.nodes.find( + (n) => n.kind === "method" && n.qualifiedName === "MyApp.Models.User.Greet", + )!; + expect( + result.edges.some( + (e) => e.kind === "contains" && e.source === userClass.id && e.target === greet.id, + ), + ).toBe(true); + }); + + it("visits declarations and references in a file-scoped namespace exactly once", () => { + const extracted = extractSource(` +using System; +namespace Demo; +class Worker { + void Start() { var worker = new Worker(); Work(); } + void Work() {} +} +class Other {} +`); + expect(extracted.nodes.map((entry) => entry.qualifiedName)).toEqual([ + "regressions.cs", "Demo", "Demo.Worker", "Demo.Worker.Start", "Demo.Worker.Work", "Demo.Other", + ]); + const worker = extracted.nodes.find((entry) => entry.qualifiedName === "Demo.Worker")!; + const namespace = extracted.nodes.find((entry) => entry.kind === "namespace")!; + const start = extracted.nodes.find((entry) => entry.qualifiedName === "Demo.Worker.Start")!; + expect(extracted.edges.filter((edge) => edge.kind === "contains" && edge.target === worker.id)) + .toEqual([{ source: namespace.id, target: worker.id, kind: "contains" }]); + expect(extracted.edges.filter((edge) => edge.kind === "calls")) + .toEqual([expect.objectContaining({ source: start.id, targetName: "Work" })]); + expect(extracted.edges.filter((edge) => edge.kind === "instantiates")) + .toEqual([expect.objectContaining({ source: start.id, targetName: "Worker" })]); + expect(extracted.edges.filter((edge) => edge.kind === "imports")) + .toEqual([expect.objectContaining({ targetName: "System" })]); + }); + + it("keeps operators, conversions, constructors, and destructors distinct across reordering", () => { + const members = [ + "public Sample() {}", + "~Sample() {}", + "public static Sample operator +(Sample left, Sample right) => left;", + "public static Sample operator -(Sample left, Sample right) => left;", + "public static Sample operator checked +(Sample left, Sample right) => left;", + "public static implicit operator int(Sample value) => 0;", + 'public static implicit operator string(Sample value) => "";', + "public static explicit operator Sample(int value) => new Sample();", + ]; + const first = extractSource(`class Sample {\n${members.join("\n")}\n}`); + const reordered = extractSource(`\n// Declarations moved without changing their identity.\nclass Sample {\n${[...members].reverse().join("\n")}\n}`); + const methods = first.nodes.filter((entry) => entry.kind === "method"); + expect(methods.map((entry) => entry.name).sort()).toEqual([ + "Sample", "~Sample", "operator +", "operator -", "operator checked +", + "implicit operator int", "implicit operator string", "explicit operator Sample", + ].sort()); + expect(new Set(methods.map((entry) => entry.id)).size).toBe(members.length); + expect(new Set(methods.map((entry) => entry.identityKey)).size).toBe(members.length); + for (const method of methods) { + const moved = reordered.nodes.find((entry) => entry.kind === "method" && entry.name === method.name)!; + expect(moved).toMatchObject({ + id: method.id, + identityKey: method.identityKey, + qualifiedName: method.qualifiedName, + signature: method.signature, + }); + } + }); + + it("keeps static and instance constructor identities and body references attached after reordering", () => { + const members = [ + "static Sample() { InitializeType(); }", + "public Sample() { InitializeInstance(); }", + ]; + const sources = [members, [...members].reverse()].map((constructors) => extractSource(` +class Sample { + ${constructors.join("\n ")} + static void InitializeType() {} + void InitializeInstance() {} +} +`)); + for (const [isStatic, name, targetName] of [ + [true, "static Sample", "InitializeType"], + [false, "Sample", "InitializeInstance"], + ] as const) { + const constructors = sources.map((extracted) => { + const matches = extracted.nodes.filter((entry) => entry.kind === "method" && entry.name === name); + expect(matches).toHaveLength(1); + const constructor = matches[0]!; + expect(constructor).toMatchObject({ isStatic, signature: "()", qualifiedName: `Sample.${name}` }); + expect(extracted.edges.filter((edge) => edge.source === constructor.id && edge.kind === "calls")) + .toEqual([expect.objectContaining({ targetName })]); + return constructor; + }); + expect(constructors[1]).toMatchObject({ + id: constructors[0]!.id, + identityKey: constructors[0]!.identityKey, + isStatic, + }); + } + }); + + it("attributes field initializer calls and constructions to each declared field", () => { + const extracted = extractSource(` +class Resource {} +class Owner { + int first = Initialize(), second = Initialize(); + Resource resource = new Resource(); + static int Initialize() => 1; +} +`); + const names = new Map(extracted.nodes.map((entry) => [entry.id, entry.qualifiedName])); + const references = extracted.edges + .filter((edge) => edge.kind === "calls" || edge.kind === "instantiates") + .map((edge) => ({ owner: names.get(edge.source), kind: edge.kind, target: edge.targetName })); + expect(references).toEqual([ + { owner: "Owner.first", kind: "calls", target: "Initialize" }, + { owner: "Owner.second", kind: "calls", target: "Initialize" }, + { owner: "Owner.resource", kind: "instantiates", target: "Resource" }, + ]); + }); + + it("extracts overloaded indexers with their signatures, parameters, and accessor calls", () => { + const extracted = extractSource(` +class Bag { + public int this[int index] => GetByIndex(index); + public int this[string key] { + get { return GetByKey(key); } + set { SetByKey(key, value); } + } + int GetByIndex(int index) => 0; + int GetByKey(string key) => 0; + void SetByKey(string key, int value) {} +} +`); + const indexers = extracted.nodes.filter((entry) => entry.kind === "property" && entry.name === "this"); + expect(indexers).toHaveLength(2); + expect(new Set(indexers.map((entry) => entry.id)).size).toBe(2); + expect(indexers.map((entry) => entry.signature)).toEqual(["[int index]", "[string key]"]); + for (const [position, parameterName, parameterType, calls] of [ + [0, "index", "int", ["GetByIndex"]], + [1, "key", "string", ["GetByKey", "SetByKey"]], + ] as const) { + const indexer = indexers[position]!; + expect(indexer).toMatchObject({ qualifiedName: "Bag.this", returnType: "int" }); + const parameter = extracted.nodes.find((entry) => + entry.kind === "parameter" && entry.qualifiedName === `Bag.this.${parameterName}`, + )!; + expect(parameter).toMatchObject({ name: parameterName, returnType: parameterType }); + expect(extracted.edges).toContainEqual({ source: indexer.id, target: parameter.id, kind: "contains" }); + expect(extracted.edges.filter((edge) => edge.kind === "calls" && edge.source === indexer.id) + .map((edge) => edge.targetName)).toEqual(calls); + } + }); + + it("uses enum member identifiers when attributes precede the member", () => { + const extracted = extractSource(` +enum State { + [System.Obsolete("legacy value")] Deprecated = 1, + [System.Obsolete] Secondary, + Active, +} +`); + const members = extracted.nodes.filter((entry) => entry.kind === "enum_member"); + expect(members.map((entry) => entry.name)).toEqual(["Deprecated", "Secondary", "Active"]); + expect(members.map((entry) => entry.qualifiedName)).toEqual([ + "State.Deprecated", "State.Secondary", "State.Active", + ]); + }); + + it("represents every inherited interface as extends", () => { + const extracted = extractSource(` +interface ILeft {} +interface IRight {} +interface ICombined : ILeft, IRight {} +`); + const combined = extracted.nodes.find((entry) => entry.name === "ICombined")!; + expect(extracted.edges.filter((edge) => edge.source === combined.id) + .map((edge) => ({ kind: edge.kind, target: edge.targetName }))).toEqual([ + { kind: "extends", target: "ILeft" }, + { kind: "extends", target: "IRight" }, + ]); + }); + + it("preserves the full receiver expression on calls", () => { + const extracted = extractSource(` +class Base { protected void Run() {} } +class Other { public void Run() {} } +static class Helpers { public static void Run() {} } +class Caller : Base { + void Start(Other other) { + other.Run(); + this.Run(); + base.Run(); + Helpers.Run(); + GetOther().Run(); + Run(); + } + Other GetOther() => new Other(); + new void Run() {} +} +`); + const start = extracted.nodes.find((entry) => entry.qualifiedName === "Caller.Start")!; + expect(extracted.edges.filter((edge) => edge.kind === "calls" && edge.source === start.id) + .map((edge) => edge.targetName)).toEqual([ + "other.Run", "this.Run", "base.Run", "Helpers.Run", "GetOther().Run", "GetOther", "Run", + ]); + }); +}); diff --git a/src/graph/__tests__/fixtures/sample.cs b/src/graph/__tests__/fixtures/sample.cs new file mode 100644 index 00000000..76137cfb --- /dev/null +++ b/src/graph/__tests__/fixtures/sample.cs @@ -0,0 +1,64 @@ +using System; +using System.Collections.Generic; + +namespace MyApp.Models +{ + public interface IGreeter + { + string Greet(); + } + + public struct Point + { + public int X; + public int Y; + } + + public enum Role + { + Admin, + Member, + } + + [Serializable] + public class User : BaseEntity, IGreeter + { + public const int MaxAge = 120; + + private string name; + + public string Name + { + get { return name; } + set { name = value; } + } + + public User(string name) + { + this.name = name; + } + + public string Greet() + { + return "Hello, " + name; + } + + public static User Create(string name) + { + var user = new User(name); + Logger.Log(user.Greet()); + return user; + } + } + + namespace Admin + { + public class AuditLog + { + public void Record(string message) + { + Console.WriteLine(message); + } + } + } +} diff --git a/src/graph/extraction/grammars.ts b/src/graph/extraction/grammars.ts index 8df70fdb..a365ba3c 100644 --- a/src/graph/extraction/grammars.ts +++ b/src/graph/extraction/grammars.ts @@ -30,6 +30,7 @@ const WASM_GRAMMAR_FILES: Partial> = { jsx: "tree-sitter-javascript.wasm", python: "tree-sitter-python.wasm", rust: "tree-sitter-rust.wasm", + csharp: "tree-sitter-c-sharp.wasm", }; let grammarHashCache: string | null = null; @@ -63,6 +64,7 @@ const EXTENSION_MAP: Record = { ".jsx": "jsx", ".py": "python", ".rs": "rust", + ".cs": "csharp", }; /** Glob pattern for every extension registered above. */ diff --git a/src/graph/extraction/languages/csharp.ts b/src/graph/extraction/languages/csharp.ts new file mode 100644 index 00000000..8b0b4ae7 --- /dev/null +++ b/src/graph/extraction/languages/csharp.ts @@ -0,0 +1,554 @@ +// ============================================================================ +// mex code-graph — C# extractor (community contribution, tree-sitter-c-sharp) +// ============================================================================ +// +// Modeled on the reference tree-sitter walker in `./python.ts` (Track A/B +// pattern, not the TypeScript compiler-based path in `../compiler.ts` — C# has +// no equivalent compiler-service integration here). +// +// Grammar facts below were verified against `tree-sitter-c-sharp@0.23.5`'s +// published `src/node-types.json`, not guessed. Two structural quirks that +// differ from Python/Rust: +// - `field_declaration` / `event_field_declaration` report NO named fields +// (unlike `class_declaration`, `method_declaration`, etc.), so a field's +// name is reached by walking `.namedChildren` by `.type` two levels down: +// field_declaration -> variable_declaration -> variable_declarator[name]. +// - `base_list` also has no fields and does not syntactically distinguish a +// base *class* from an implemented *interface* — both are just entries in +// one list. This extractor applies the conventional heuristic (base class, +// if any, is listed first) for `class`/`struct`/`record`; every entry on an +// `interface_declaration` is `extends` (an interface can only extend other +// interfaces). This is a documented best-effort, not a semantic guarantee. + +import type { Language, NodeKind } from "../../types.js"; +import type { + ExtractedEdge, + ExtractedNode, + LanguageExtractor, + TSNode, + TSTree, +} from "../types.js"; +import { canonicalNodeIdentity, generateNodeId, getChildByField, getNodeText } from "../node-id.js"; + +const TYPE_DECLARATION_KINDS: Record = { + class_declaration: "class", + struct_declaration: "struct", + record_declaration: "class", // no dedicated "record" NodeKind yet + interface_declaration: "interface", +}; +const TYPE_DECLARATION_TYPES = new Set(Object.keys(TYPE_DECLARATION_KINDS)); + +const METHOD_TYPES = new Set([ + "method_declaration", + "constructor_declaration", + "destructor_declaration", + "operator_declaration", + "conversion_operator_declaration", +]); +const LOCAL_FUNCTION_TYPES = new Set(["local_function_statement"]); +const PROPERTY_TYPES = new Set(["property_declaration", "indexer_declaration", "event_declaration"]); +const FIELD_DECL_TYPES = new Set(["field_declaration", "event_field_declaration"]); +const ENUM_TYPES = new Set(["enum_declaration"]); +const DELEGATE_TYPES = new Set(["delegate_declaration"]); +const NAMESPACE_TYPES = new Set(["namespace_declaration", "file_scoped_namespace_declaration"]); +const CALL_TYPES = new Set(["invocation_expression"]); +const INSTANTIATION_TYPES = new Set(["object_creation_expression", "implicit_object_creation_expression"]); + +const VISIBILITY_MODIFIERS = new Set(["public", "private", "protected", "internal"]); + +class CSharpWalker { + private readonly nodes: ExtractedNode[] = []; + private readonly edges: ExtractedEdge[] = []; + private readonly scopeStack: string[] = []; + private readonly identityOccurrences = new Map(); + + constructor( + private readonly filePath: string, + private readonly source: string, + private readonly language: Language, + ) {} + + run(root: TSNode): { nodes: ExtractedNode[]; edges: ExtractedEdge[] } { + const fileName = baseName(this.filePath); + const fileId = generateNodeId(this.filePath, "file", fileName, this.filePath, "source-file"); + this.nodes.push({ + id: fileId, + identityKey: canonicalNodeIdentity(this.filePath, "file", this.filePath, "source-file"), + kind: "file", + name: fileName, + qualifiedName: this.filePath, + filePath: this.filePath, + language: this.language, + startLine: 1, + endLine: root.endPosition.row + 1, + startColumn: 0, + endColumn: 0, + isExported: false, + }); + + this.scopeStack.push(fileId); + for (const child of root.namedChildren) { + this.visit(child); + // extractNamespace owns all later siblings of a file-scoped namespace. + if (child.type === "file_scoped_namespace_declaration") break; + } + this.scopeStack.pop(); + + return { nodes: this.nodes, edges: this.edges }; + } + + private visit(node: TSNode): void { + const type = node.type; + + if (NAMESPACE_TYPES.has(type)) return this.extractNamespace(node); + if (TYPE_DECLARATION_TYPES.has(type)) return this.extractTypeDeclaration(node); + if (ENUM_TYPES.has(type)) return this.extractEnum(node); + if (DELEGATE_TYPES.has(type)) return this.extractDelegate(node); + if (METHOD_TYPES.has(type) || LOCAL_FUNCTION_TYPES.has(type)) { + return this.extractMethod(node, LOCAL_FUNCTION_TYPES.has(type)); + } + if (PROPERTY_TYPES.has(type)) return this.extractProperty(node); + if (FIELD_DECL_TYPES.has(type)) return this.extractFieldDeclaration(node); + if (type === "using_directive") return this.extractUsing(node); + + // Namespaces, blocks, and statements nest arbitrarily; keep descending + // until a declaration or a call/instantiation site is found. + for (const child of node.namedChildren) this.visit(child); + } + + private createNode( + kind: NodeKind, + name: string, + node: TSNode, + extra?: Partial, + ): string | null { + if (!name) return null; + const qualifiedName = this.qualify(name); + const baseIdentity = canonicalNodeIdentity(this.filePath, kind, qualifiedName, kind, extra?.signature); + const ordinal = this.identityOccurrences.get(baseIdentity) ?? 0; + this.identityOccurrences.set(baseIdentity, ordinal + 1); + const declarationRole = ordinal === 0 ? kind : `${kind}:ordinal:${ordinal}`; + const identityKey = canonicalNodeIdentity(this.filePath, kind, qualifiedName, declarationRole, extra?.signature); + const id = generateNodeId(this.filePath, kind, name, qualifiedName, declarationRole, extra?.signature); + this.nodes.push({ + id, + identityKey, + kind, + name, + qualifiedName, + filePath: this.filePath, + language: this.language, + startLine: node.startPosition.row + 1, + endLine: node.endPosition.row + 1, + startColumn: node.startPosition.column, + endColumn: node.endPosition.column, + ...extra, + }); + + const parent = this.scopeStack[this.scopeStack.length - 1]; + if (parent) this.edges.push({ source: parent, target: id, kind: "contains" }); + return id; + } + + private qualify(name: string): string { + const parts: string[] = []; + for (const scopeId of this.scopeStack) { + const scope = this.nodes.find((n) => n.id === scopeId); + if (scope && scope.kind !== "file") parts.push(scope.name); + } + parts.push(name); + return parts.join("."); + } + + private extractNamespace(node: TSNode): void { + const nameNode = getChildByField(node, "name"); + const name = nameNode ? getNodeText(nameNode, this.source) : ""; + if (!name) return; + const id = this.createNode("namespace", name, node); + if (!id) return; + + const body = getChildByField(node, "body"); + this.scopeStack.push(id); + if (body) { + for (const child of body.namedChildren) this.visit(child); + } else { + // File-scoped namespace: `namespace Foo;` — every following declaration + // in the file belongs to it, tree-sitter keeps them as later siblings. + let sibling = node.nextNamedSibling; + while (sibling) { + this.visit(sibling); + sibling = sibling.nextNamedSibling; + } + } + this.scopeStack.pop(); + } + + private extractTypeDeclaration(node: TSNode): void { + const nameNode = getChildByField(node, "name"); + const name = nameNode ? getNodeText(nameNode, this.source) : ""; + if (!name) return; + + const kind = TYPE_DECLARATION_KINDS[node.type]!; + const modifiers = modifiersOf(node, this.source); + const id = this.createNode(kind, name, node, { + visibility: visibilityOf(modifiers), + isExported: modifiers.includes("public"), + isAbstract: modifiers.includes("abstract"), + isStatic: modifiers.includes("static"), + }); + if (!id) return; + + this.extractHeritage(node, id, kind === "interface"); + this.extractAttributes(node, id); + + const body = getChildByField(node, "body"); + this.scopeStack.push(id); + if (body) for (const member of body.namedChildren) this.visit(member); + this.scopeStack.pop(); + } + + private extractEnum(node: TSNode): void { + const nameNode = getChildByField(node, "name"); + const name = nameNode ? getNodeText(nameNode, this.source) : ""; + if (!name) return; + const modifiers = modifiersOf(node, this.source); + const id = this.createNode("enum", name, node, { + visibility: visibilityOf(modifiers), + isExported: modifiers.includes("public"), + }); + if (!id) return; + + const body = getChildByField(node, "body"); // enum_member_declaration_list + if (!body) return; + this.scopeStack.push(id); + for (const member of body.namedChildren) { + if (member.type !== "enum_member_declaration") continue; + const memberNameNode = getChildByField(member, "name"); + const memberName = memberNameNode ? getNodeText(memberNameNode, this.source) : ""; + this.createNode("enum_member", memberName, member); + } + this.scopeStack.pop(); + } + + private extractDelegate(node: TSNode): void { + const nameNode = getChildByField(node, "name"); + const name = nameNode ? getNodeText(nameNode, this.source) : ""; + if (!name) return; + const modifiers = modifiersOf(node, this.source); + const returnType = getChildByField(node, "type"); + // No dedicated "delegate" NodeKind: a delegate is a named callable type, + // closest existing kind is type_alias. + this.createNode("type_alias", name, node, { + visibility: visibilityOf(modifiers), + isExported: modifiers.includes("public"), + returnType: returnType ? getNodeText(returnType, this.source) : undefined, + signature: signatureOf(node, this.source), + }); + } + + private extractMethod(node: TSNode, isLocalFunction: boolean): void { + const name = methodNameOf(node, this.source); + if (!name) return; + + const modifiers = modifiersOf(node, this.source); + const returnType = getChildByField(node, "returns") ?? getChildByField(node, "type"); + const id = this.createNode(isLocalFunction ? "function" : "method", name, node, { + visibility: visibilityOf(modifiers), + isExported: modifiers.includes("public"), + isStatic: modifiers.includes("static"), + isAbstract: modifiers.includes("abstract"), + isAsync: modifiers.includes("async"), + returnType: returnType ? getNodeText(returnType, this.source) : undefined, + signature: signatureOf(node, this.source), + }); + if (!id) return; + + this.extractAttributes(node, id); + this.scopeStack.push(id); + this.extractParameters(node); + this.scopeStack.pop(); + + const body = getChildByField(node, "body") ?? getChildByField(node, "value"); + if (body) this.walkBody(body, id); + } + + private extractProperty(node: TSNode): void { + const nameNode = getChildByField(node, "name"); + // Indexers expose a `this` token and bracketed parameters, but no name field. + const name = node.type === "indexer_declaration" + ? "this" + : nameNode ? getNodeText(nameNode, this.source) : ""; + if (!name) return; + const modifiers = modifiersOf(node, this.source); + const type = getChildByField(node, "type"); + const id = this.createNode("property", name, node, { + visibility: visibilityOf(modifiers), + isExported: modifiers.includes("public"), + isStatic: modifiers.includes("static"), + returnType: type ? getNodeText(type, this.source) : undefined, + signature: signatureOf(node, this.source), + }); + if (!id) return; + + this.extractAttributes(node, id); + this.scopeStack.push(id); + this.extractParameters(node); + this.scopeStack.pop(); + const value = getChildByField(node, "value"); + if (value) this.walkBody(value, id); + // Accessor bodies (get/set with expression bodies) can contain calls too. + const accessors = getChildByField(node, "accessors"); + if (accessors) this.walkBody(accessors, id); + } + + /** + * `field_declaration` / `event_field_declaration` expose no named fields + * (verified: `node-types.json` reports `fields: {}` for both). Structure is + * `field_declaration -> variable_declaration -> variable_declarator[name]`, + * and a single declaration can list multiple comma-separated declarators + * (`int x, y;`) — each becomes its own field node. + */ + private extractFieldDeclaration(node: TSNode): void { + const modifiers = modifiersOf(node, this.source); + const varDecl = node.namedChildren.find((child) => child.type === "variable_declaration"); + if (!varDecl) return; + const type = getChildByField(varDecl, "type"); + + // `const` fields have no runtime storage location distinct from their + // value — mirror Rust's const_item -> "constant" mapping (rust.ts:104,308) + // rather than emitting them as ordinary mutable fields. + const isConst = modifiers.includes("const"); + + for (const declarator of varDecl.namedChildren) { + if (declarator.type !== "variable_declarator") continue; + const nameNode = getChildByField(declarator, "name"); + const name = nameNode ? getNodeText(nameNode, this.source) : ""; + if (!name) continue; + const id = this.createNode(isConst ? "constant" : "field", name, declarator, { + visibility: visibilityOf(modifiers), + isExported: modifiers.includes("public"), + isStatic: modifiers.includes("static") || isConst, + returnType: type ? getNodeText(type, this.source) : undefined, + }); + if (id) { + this.extractAttributes(node, id); + // The initializer is an unnamed-field child of this declarator in + // 0.23.5. Walking only this declarator preserves each field's ownership. + this.walkBody(declarator, id); + } + } + } + + /** + * `using_directive`'s `name` field is documented in `node-types.json` but was + * observed unset at runtime for plain `using X;` / `using X.Y;` forms in the + * vendored `tree-sitter-c-sharp@0.23.5` grammar — verified against this + * fixture, not assumed. Fall back to the (single, unambiguous) first named + * child, matching the defensive field-or-positional pattern other extractors + * use for the same class of grammar quirk (e.g. `python.ts`'s + * `getChildByField(node, "left") ?? node.namedChild(0)`). + */ + private extractUsing(node: TSNode): void { + const fileId = this.scopeStack[0]; + if (!fileId) return; + const nameNode = getChildByField(node, "name") ?? node.namedChild(0); + const name = nameNode ? getNodeText(nameNode, this.source) : ""; + if (!name) return; + this.addRef(fileId, name, "imports", node); + } + + /** + * `base_list` reports no fields (verified: `node-types.json`); its children + * are `type` / `primary_constructor_base_type` entries with no syntactic + * marker for "base class" vs. "implemented interface". Heuristic: on an + * interface every entry is `extends`; on a class/struct/record, the first + * entry is treated as the base class (`extends`) and the rest as + * `implements` — the common convention, not a grammar guarantee. + */ + private extractHeritage(node: TSNode, fromId: string, isInterface: boolean): void { + const baseList = node.namedChildren.find((child) => child.type === "base_list"); + if (!baseList) return; + const entries = baseList.namedChildren.filter( + (child) => child.type === "type" || child.type === "identifier" || child.type === "generic_name" + || child.type === "qualified_name" || child.type === "primary_constructor_base_type", + ); + entries.forEach((entry, index) => { + const kind = !isInterface && index > 0 ? "implements" : "extends"; + this.addRef(fromId, getNodeText(entry, this.source), kind, entry); + }); + } + + private extractAttributes(node: TSNode, ownerId: string): void { + for (const child of node.namedChildren) { + if (child.type !== "attribute_list") continue; + for (const attribute of child.namedChildren) { + if (attribute.type !== "attribute") continue; + const nameNode = getChildByField(attribute, "name"); + const name = nameNode ? getNodeText(nameNode, this.source) : ""; + if (name) this.addRef(ownerId, name, "decorates", attribute); + } + } + } + + /** Caller must have the owning method or indexer pushed as the current scope. */ + private extractParameters(node: TSNode): void { + const params = getChildByField(node, "parameters"); + if (!params) return; + for (const param of params.namedChildren) { + if (param.type !== "parameter") continue; + const nameNode = getChildByField(param, "name"); + const name = nameNode ? getNodeText(nameNode, this.source) : ""; + if (!name) continue; + const type = getChildByField(param, "type"); + this.createNode("parameter", name, param, { + returnType: type ? getNodeText(type, this.source) : undefined, + }); + } + } + + private walkBody(body: TSNode, ownerId: string): void { + const type = body.type; + + if (CALL_TYPES.has(type)) { + this.extractCall(body, ownerId); + } else if (INSTANTIATION_TYPES.has(type)) { + this.extractInstantiation(body, ownerId); + } else if (METHOD_TYPES.has(type) || LOCAL_FUNCTION_TYPES.has(type)) { + this.scopeStack.push(ownerId); + this.visit(body); + this.scopeStack.pop(); + return; + } else if (TYPE_DECLARATION_TYPES.has(type)) { + // Local/nested type declarations inside a method body. + this.scopeStack.push(ownerId); + this.visit(body); + this.scopeStack.pop(); + return; + } + + for (const child of body.namedChildren) this.walkBody(child, ownerId); + } + + private extractCall(node: TSNode, ownerId: string): void { + const fn = getChildByField(node, "function"); + // Retain the receiver so resolution can distinguish this.M() from other.M(). + let calleeName = fn ? getNodeText(fn, this.source) : ""; + if (fn?.type === "member_access_expression") { + const name = getChildByField(fn, "name"); + // `this` and `base` are unnamed tokens without an expression field in + // 0.23.5. Read the member parts to omit comments/spacing around the dot, + // while preserving calls, casts, and other receiver expressions intact. + const receiver = getChildByField(fn, "expression") + ?? fn.children.find((child) => child.type === "this" || child.type === "base"); + if (receiver && name) { + calleeName = `${getNodeText(receiver, this.source)}.${getNodeText(name, this.source)}`; + } + } + if (calleeName) this.addRef(ownerId, calleeName, "calls", node); + } + + private extractInstantiation(node: TSNode, ownerId: string): void { + const typeNode = getChildByField(node, "type"); + const typeName = typeNode ? getNodeText(typeNode, this.source) : ""; + if (typeName) this.addRef(ownerId, typeName, "instantiates", node); + } + + private addRef( + source: string, + targetName: string, + kind: ExtractedEdge["kind"], + node: TSNode, + metadata?: Record, + ): void { + if (!targetName) return; + this.edges.push({ + source, + targetName, + kind, + line: node.startPosition.row, + column: node.startPosition.column, + metadata, + }); + } +} + +// ---------------------------------------------------------------------------- +// C# node helpers +// ---------------------------------------------------------------------------- + +/** `modifier` nodes have no fields (verified in `node-types.json`) — read their text directly. */ +function modifiersOf(node: TSNode, source: string): string[] { + return node.namedChildren + .filter((child) => child.type === "modifier") + .map((child) => getNodeText(child, source)); +} + +function visibilityOf(modifiers: string[]): "public" | "private" | "protected" | "internal" | undefined { + return modifiers.find((m): m is "public" | "private" | "protected" | "internal" => + VISIBILITY_MODIFIERS.has(m), + ); +} + +function signatureOf(node: TSNode, source: string): string | undefined { + const params = getChildByField(node, "parameters"); + if (!params) return undefined; + return getNodeText(params, source); +} + +function methodNameOf(node: TSNode, source: string): string { + if (node.type === "operator_declaration") { + // `operator` is a field on the punctuation/keyword token, including >>>. + const operator = getChildByField(node, "operator"); + if (!operator) return ""; + const checked = node.children.some((child) => child.type === "checked"); + return `operator ${checked ? "checked " : ""}${getNodeText(operator, source)}`; + } + if (node.type === "conversion_operator_declaration") { + const conversion = node.children.find((child) => child.type === "implicit" || child.type === "explicit"); + const type = getChildByField(node, "type"); + if (!conversion || !type) return ""; + const checked = node.children.some((child) => child.type === "checked"); + return `${getNodeText(conversion, source)} operator ${checked ? "checked " : ""}${getNodeText(type, source)}`; + } + + const nameNode = getChildByField(node, "name"); + const name = nameNode ? getNodeText(nameNode, source) + : node.type === "constructor_declaration" || node.type === "destructor_declaration" + ? enclosingTypeName(node, source) : ""; + if (!name) return ""; + if (node.type === "destructor_declaration") return `~${name}`; + // Static initializers and parameterless instance constructors can coexist; + // both expose the same name and parameter list in the grammar. + if (node.type === "constructor_declaration" && modifiersOf(node, source).includes("static")) { + return `static ${name}`; + } + return name; +} + +/** Constructors/destructors: fall back to the nearest enclosing type's name. */ +function enclosingTypeName(node: TSNode, source: string): string { + let current: TSNode | null = node.parent; + while (current) { + if (TYPE_DECLARATION_TYPES.has(current.type)) { + const nameField = getChildByField(current, "name"); + if (nameField) return getNodeText(nameField, source); + } + current = current.parent; + } + return ""; +} + +function baseName(filePath: string): string { + const normalized = filePath.replace(/\\/g, "/"); + const slash = normalized.lastIndexOf("/"); + return slash < 0 ? normalized : normalized.slice(slash + 1); +} + +export const csharpExtractor: LanguageExtractor = { + language: "csharp", + fileExtensions: [".cs"], + grammarWasm: "tree-sitter-c-sharp.wasm", + extract(tree: TSTree, filePath: string, source: string) { + return new CSharpWalker(filePath, source, "csharp").run(tree.rootNode); + }, +}; diff --git a/src/graph/extraction/languages/index.ts b/src/graph/extraction/languages/index.ts index de5e34ad..16662522 100644 --- a/src/graph/extraction/languages/index.ts +++ b/src/graph/extraction/languages/index.ts @@ -12,6 +12,7 @@ import { typescriptExtractor, tsxExtractor } from "./typescript.js"; import { javascriptExtractor, jsxExtractor } from "./javascript.js"; import { pythonExtractor } from "./python.js"; import { rustExtractor } from "./rust.js"; +import { csharpExtractor } from "./csharp.js"; /** Registered extractors, keyed by the language id they emit. */ export const EXTRACTORS: Partial> = { @@ -21,6 +22,7 @@ export const EXTRACTORS: Partial> = { jsx: jsxExtractor, python: pythonExtractor, rust: rustExtractor, + csharp: csharpExtractor, }; /** The extractor for a language, or undefined if unsupported in this release. */ diff --git a/src/graph/resolution/resolver.ts b/src/graph/resolution/resolver.ts index 1ae7e76a..9830a453 100644 --- a/src/graph/resolution/resolver.ts +++ b/src/graph/resolution/resolver.ts @@ -177,10 +177,14 @@ export function resolveReferences( if (!candidates || candidates.length === 0) continue; const allowedKinds = TARGET_KINDS[ref.referenceKind] ?? []; - const filtered = - allowedKinds.length === 0 - ? candidates.filter((n) => n.id !== ref.fromNodeId) - : candidates.filter((n) => allowedKinds.includes(n.kind) && n.id !== ref.fromNodeId); + // A recursive C# call is still a candidate for itself. Removing it can + // turn an unresolved overload set into a false, confident sibling edge. + const allowSelf = ref.language === "csharp" + && (ref.referenceKind === "calls" || ref.referenceKind === "function_ref"); + const filtered = candidates.filter((node) => + (allowedKinds.length === 0 || allowedKinds.includes(node.kind)) + && (allowSelf || node.id !== ref.fromNodeId), + ); if (filtered.length === 0) continue; const provenFiles = new Set([ @@ -229,16 +233,29 @@ function pickBest( ref: UnresolvedRefRecord, importedFiles: Set | undefined, ): GraphNode | null { + const receiver = ref.receiver ?? ref.referenceName.split(".").slice(0, -1).join("."); + // A C# qualifier may name an object, namespace, type, or alias. Syntax alone + // cannot bind it to a same-named lexical symbol, including in heritage and + // construction references. Only `this` calls have a proven lexical receiver. + if (ref.language === "csharp") { + const isThisCall = receiver.trim() === "this" + && (ref.referenceKind === "calls" || ref.referenceKind === "function_ref"); + const isQualified = receiver.trim() !== "" || ref.referenceName.includes("::"); + if (isQualified && !isThisCall) return null; + } + const sameFile = candidates.filter((n) => n.filePath === fromNode.filePath); if (sameFile.length > 0) { // `this`/`super` and unqualified names may bind only inside the lexical // container. Never pick the first same-named method in the file. - const receiver = ref.receiver ?? ref.referenceName.split(".").slice(0, -1).join("."); - const lexical = sameFile.filter((node) => - node.containerId === fromNode.containerId - || node.containerId === fromNode.id - || (receiver === "this" && node.containerId === fromNode.containerId), - ); + const lexical = sameFile.filter((node) => { + // `this.M()` names a member, never a local function named M in the + // calling method. Nested/local functions have no proven type receiver. + if (ref.language === "csharp" && receiver === "this") { + return node.kind === "method" && node.containerId === fromNode.containerId; + } + return node.containerId === fromNode.containerId || node.containerId === fromNode.id; + }); if (lexical.length === 1) return lexical[0]!; if (lexical.length > 1) return null; const moduleLevel = sameFile.filter((node) => !node.containerId); diff --git a/src/graph/wasm/tree-sitter-c-sharp.wasm b/src/graph/wasm/tree-sitter-c-sharp.wasm new file mode 100755 index 00000000..bddfd5c8 Binary files /dev/null and b/src/graph/wasm/tree-sitter-c-sharp.wasm differ