From 8e54feb0a2c1af70ce90645dc5d49b96b6988cc3 Mon Sep 17 00:00:00 2001 From: Mohit Agarwal Date: Wed, 23 Sep 2026 07:44:40 +0530 Subject: [PATCH] feat(graph): add Go language extractor Add code-graph extraction support for Go, wired through the contributor seam in docs/extractors.md: a GoWalker over the tree-sitter-go grammar, registered in the extractor registry and the grammar map. - Extract structs (class), interfaces, type aliases, functions, methods, generics, imports, calls, struct instantiations, and struct field types, with signatures, docstrings, visibility, and export state. - Methods bind to their receiver type through a deferred second pass, so a method resolves to a type declared later in the file with a single containment parent and a receiver-stable identity. - Vendor the grammar's own published wasm, tree-sitter-go@0.25.0 (https://github.com/tree-sitter/tree-sitter-go), at src/graph/wasm/tree-sitter-go.wasm. - Add fixture sample.go and 12 tests covering declarations, containment, references, generics, docstrings, multi-name declarations, and declaration-order independence. - Retarget the coverage-histogram fixtures to .java now that .go is an indexed source extension, so unindexed-source examples stay accurate. - Document Go support in docs/code-graph-support.md and the grammar provenance in docs/extractors.md. Fixes #93 --- docs/code-graph-support.md | 5 +- docs/extractors.md | 8 + src/graph/__tests__/cli-agent.test.ts | 6 +- .../__tests__/coverage-histogram.test.ts | 26 +- src/graph/__tests__/extractor-go.test.ts | 147 +++++ src/graph/__tests__/fixtures/sample.go | 80 +++ src/graph/extraction/grammars.ts | 2 + src/graph/extraction/languages/go.ts | 564 ++++++++++++++++++ src/graph/extraction/languages/index.ts | 2 + src/graph/wasm/tree-sitter-go.wasm | Bin 0 -> 217182 bytes 10 files changed, 822 insertions(+), 18 deletions(-) create mode 100644 src/graph/__tests__/extractor-go.test.ts create mode 100644 src/graph/__tests__/fixtures/sample.go create mode 100644 src/graph/extraction/languages/go.ts create mode 100644 src/graph/wasm/tree-sitter-go.wasm diff --git a/docs/code-graph-support.md b/docs/code-graph-support.md index d1233e8a..c2829477 100644 --- a/docs/code-graph-support.md +++ b/docs/code-graph-support.md @@ -27,7 +27,8 @@ extractor registry lives in | **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. | +| **Supported** | Go | `.go` | [`sample.go`](../src/graph/__tests__/fixtures/sample.go) and [`extractor-go.test.ts`](../src/graph/__tests__/extractor-go.test.ts) cover structs, interfaces, type aliases, functions, methods, generics, imports, calls, and struct field types. | +| **Unsupported** | 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 in that type union is not a support promise; the grammar and extractor @@ -242,7 +243,7 @@ either resolve the requested declaration exactly or abstain. until their language extractor and resolver work merges. - **Support claims are fixture-bounded.** This page describes behavior exercised in v0.7.0. It does not promise complete semantic analysis for every construct - in a supported language or support for unmerged Go, NestJS, or Next.js work. + in a supported language or support for unmerged NestJS or Next.js work. For contributor interfaces, fixture requirements, and registration points, see [Extending the code graph](extractors.md). diff --git a/docs/extractors.md b/docs/extractors.md index 43ef468e..2f256791 100644 --- a/docs/extractors.md +++ b/docs/extractors.md @@ -127,6 +127,14 @@ When adding a language, document the grammar source and version. - **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. +### Go +- **Binary source:** `tree-sitter-go` package, version `0.25.0` +- **Vendored path:** `src/graph/wasm/tree-sitter-go.wasm` +- **SHA-256:** `9504573f352b20be7f2f1911754d710622aedc15afff16d5ed8fb5645681aee7` (matches `tree-sitter-go@0.25.0/tree-sitter-go.wasm`) +- **Binary package license:** MIT +- **Upstream grammar:** [tree-sitter/tree-sitter-go](https://github.com/tree-sitter/tree-sitter-go) +- **Upstream grammar license:** MIT + ## Pull request proof Before opening a pull request, run: diff --git a/src/graph/__tests__/cli-agent.test.ts b/src/graph/__tests__/cli-agent.test.ts index 34fdf75e..83ee68c4 100644 --- a/src/graph/__tests__/cli-agent.test.ts +++ b/src/graph/__tests__/cli-agent.test.ts @@ -1375,7 +1375,7 @@ describe("runGraphQuery", () => { "export function fetchOrders(userId: string): string[] {\n return [userId];\n}\n", ); writeFileSync(join(mixedRoot, "OrderList.svelte"), "\n"); - writeFileSync(join(mixedRoot, "main.go"), "package main\n"); + writeFileSync(join(mixedRoot, "main.java"), "class Main {}\n"); mixedEngine = createGraphEngine({ rootDir: mixedRoot }); await mixedEngine.build(mixedRoot); db = openSqlite(join(mixedRoot, ".mex", "graph.db")); @@ -1391,7 +1391,7 @@ describe("runGraphQuery", () => { code: "TARGET_NOT_FOUND", target: "refreshOrders", filesIndexed: 1, - unindexedSources: { total: 2, byExtension: { ".go": 1, ".svelte": 1 }, truncated: false }, + unindexedSources: { total: 2, byExtension: { ".java": 1, ".svelte": 1 }, truncated: false }, }); const coverageWarnings = (records: Record[]) => ((records.at(-1)?.warnings ?? []) as string[]) .filter((warning) => warning.includes("coverage")); @@ -1415,7 +1415,7 @@ describe("runGraphQuery", () => { const changed = capture(() => runGraphQuery("where-defined", "refreshOrders", mixedRoot, mixedDeps, {})); expect(changed.find((record) => record.code === "TARGET_NOT_FOUND")).toMatchObject({ filesIndexed: 1, - unindexedSources: { total: 2, byExtension: { ".go": 1, ".svelte": 1 }, truncated: false, observedAt: "last-build" }, + unindexedSources: { total: 2, byExtension: { ".java": 1, ".svelte": 1 }, truncated: false, observedAt: "last-build" }, }); const changedScope = capture(() => runGraphScope("checkout cart pricing", mixedRoot, mixedDeps, {})); expect(coverageWarnings(changedScope)).toEqual([expect.stringContaining("at the last graph build")]); diff --git a/src/graph/__tests__/coverage-histogram.test.ts b/src/graph/__tests__/coverage-histogram.test.ts index b29fc2bc..c97127c8 100644 --- a/src/graph/__tests__/coverage-histogram.test.ts +++ b/src/graph/__tests__/coverage-histogram.test.ts @@ -33,8 +33,8 @@ function source(root: string, path: string, contents: string): void { describe("unindexedExtensionHistogram", () => { it("reports recognized source extensions no extractor handles", () => { const root = temporaryRoot(); - source(root, "main.go", "package main\n"); - source(root, "internal/server/server.go", "package server\n"); + source(root, "main.java", "class Main {}\n"); + source(root, "internal/server/server.java", "package server;\n"); source(root, "src/App.svelte", "\n"); source(root, "src/api.ts", "export const api = true;\n"); @@ -42,7 +42,7 @@ describe("unindexedExtensionHistogram", () => { expect(coverage.total).toBe(3); expect(coverage.entries).toEqual([ - { extension: ".go", files: 2 }, + { extension: ".java", files: 2 }, { extension: ".svelte", files: 1 }, ]); expect(coverage.truncated).toBe(false); @@ -92,29 +92,29 @@ describe("unindexedExtensionHistogram", () => { const limits = { maxUnindexedFiles: 50, maxUnindexedEntries: 24 }; // Only counted files, so the processed-file cap maps exactly onto the total. for (let i = 0; i < limits.maxUnindexedFiles + 10; i++) { - source(root, `generated/file-${i}.go`, "package main\n"); + source(root, `generated/file-${i}.java`, "class File {}\n"); } const coverage = unindexedExtensionHistogram(root, limits); expect(coverage.truncated).toBe(true); expect(coverage.total).toBe(limits.maxUnindexedFiles); - expect(coverage.entries[0]).toEqual({ extension: ".go", files: limits.maxUnindexedFiles }); + expect(coverage.entries[0]).toEqual({ extension: ".java", files: limits.maxUnindexedFiles }); }); it("caps the reported entries at maxUnindexedEntries, most common first", () => { const root = temporaryRoot(); const limits = { maxUnindexedFiles: 1000, maxUnindexedEntries: 3 }; - for (const extension of [".go", ".rb", ".java", ".kt", ".scala"]) { + for (const extension of [".cpp", ".rb", ".java", ".kt", ".scala"]) { source(root, `src/main${extension}`, "x\n"); } - source(root, "src/extra.go", "package extra\n"); + source(root, "src/extra.cpp", "int extra;\n"); const coverage = unindexedExtensionHistogram(root, limits); expect(coverage.total).toBe(6); expect(coverage.entries).toEqual([ - { extension: ".go", files: 2 }, + { extension: ".cpp", files: 2 }, { extension: ".java", files: 1 }, { extension: ".kt", files: 1 }, ]); @@ -130,7 +130,7 @@ describe("build-time coverage cache", () => { it("counts 600 candidates exactly in a 30k-file tree", () => { const root = temporaryRoot(); for (let i = 0; i < 30_000; i++) source(root, `data/${i}.txt`, ""); - for (let i = 0; i < 600; i++) source(root, `src/${i}.go`, "package main"); + for (let i = 0; i < 600; i++) source(root, `src/${i}.java`, "class File {}"); const raw = captureGraphCoverage(root); expect(readGraphCoverage(cachedDb(raw), root, true)).toMatchObject({ total: 600, truncated: false }); // Large fixture cleanup belongs to this test's explicit runtime budget. @@ -140,7 +140,7 @@ describe("build-time coverage cache", () => { it("reuses metadata without any directory discovery on reads", () => { const root = temporaryRoot(); - source(root, "src/main.go", "package main"); + source(root, "src/main.java", "class Main {}"); const raw = captureGraphCoverage(root); const spy = vi.spyOn(fs, "opendirSync").mockImplementation(() => { throw new Error("read-time walk"); }); try { @@ -180,9 +180,9 @@ describe("build-time coverage cache", () => { it("keeps build-time counts, unstamped, once the entry or directory budget is crossed", () => { const root = temporaryRoot(); - source(root, "a/one.go", "package a"); + source(root, "a/one.java", "class One {}"); source(root, "b/two.vue", "