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
9 changes: 8 additions & 1 deletion .mex/patterns/INDEX.md
Original file line number Diff line number Diff line change
@@ -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 |
|---------|----------|
72 changes: 72 additions & 0 deletions .mex/patterns/syntax-extractor-review.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions docs/code-graph-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions docs/extractors.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
205 changes: 205 additions & 0 deletions src/graph/__tests__/engine-csharp.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
});
});
Loading
Loading