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", "");
- source(root, "c/three.go", "package c");
+ source(root, "c/three.java", "class Three {}");
const huge = vi.spyOn(fs, "opendirSync").mockImplementation(() => ({
readSync: () => ({ name: "entry" }), closeSync: () => {},
}) as unknown as fs.Dir);
@@ -216,7 +216,7 @@ describe("build-time coverage cache", () => {
it("verifies stamps with lstat alone, never realpath, on reads", () => {
const root = temporaryRoot();
- source(root, "src/deep/main.go", "package main");
+ source(root, "src/deep/main.java", "class Main {}");
const raw = captureGraphCoverage(root);
const realpath = vi.spyOn(fs, "realpathSync").mockImplementation(() => { throw new Error("read-time realpath"); });
try {
diff --git a/src/graph/__tests__/extractor-go.test.ts b/src/graph/__tests__/extractor-go.test.ts
new file mode 100644
index 00000000..60d696e6
--- /dev/null
+++ b/src/graph/__tests__/extractor-go.test.ts
@@ -0,0 +1,147 @@
+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.go");
+
+describe("Go extractor", () => {
+ let result: FileExtraction;
+
+ beforeAll(async () => {
+ await loadGrammars(["go"]);
+ const source = readFileSync(FIXTURE, "utf-8");
+ result = extractFile("fixtures/sample.go", source, "go")!;
+ 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);
+
+ it("emits a file node and stamps the language", () => {
+ expect(result.language).toBe("go");
+ expect(node("file", "sample.go")).toBeDefined();
+ });
+
+ it("extracts structs", () => {
+ const user = node("class", "User");
+ expect(user).toBeDefined();
+ expect(user!.isExported).toBe(true);
+ expect(user!.docstring).toContain("represents a user");
+
+ expect(node("property", "Name")).toBeDefined();
+ expect(node("property", "Age")).toBeDefined();
+
+ const order = node("class", "Order");
+ expect(order).toBeDefined();
+ expect(node("property", "ID")).toBeDefined();
+ expect(node("property", "Items")).toBeDefined();
+ });
+
+ it("extracts type aliases", () => {
+ expect(node("type_alias", "Role")).toBeDefined();
+ });
+
+ it("extracts functions and methods", () => {
+ const createUser = node("function", "CreateUser");
+ expect(createUser).toBeDefined();
+ expect(createUser!.isExported).toBe(true);
+
+ const greet = result.nodes.find(
+ (n) => n.kind === "method" && n.qualifiedName === "User::Greet",
+ );
+ expect(greet).toBeDefined();
+ expect(greet!.qualifiedName).toBe("User::Greet");
+ });
+
+ it("extracts interfaces", () => {
+ const greeter = node("interface", "Greeter");
+ expect(greeter).toBeDefined();
+ expect(greeter!.isExported).toBe(true);
+
+ const repo = node("interface", "Repo");
+ expect(repo).toBeDefined();
+ expect(repo!.typeParameters).toEqual(["T"]);
+ });
+
+ it("extracts constants and variables", () => {
+ expect(node("constant", "RoleAdmin")).toBeDefined();
+ expect(node("constant", "RoleMember")).toBeDefined();
+ expect(node("variable", "globalFlag")).toBeDefined();
+ // Every name of a multi-name declaration is emitted, not just the first.
+ expect(node("variable", "count")).toBeDefined();
+ expect(node("variable", "total")).toBeDefined();
+ });
+
+ it("emits import edges", () => {
+ expect(hasEdge("imports", "fmt")).toBe(true);
+ expect(hasEdge("imports", "strings")).toBe(true);
+ });
+
+ it("emits calls and implements references", () => {
+ expect(hasEdge("calls", "fmt.Sprintf")).toBe(true);
+ expect(hasEdge("calls", "processOrder")).toBe(true);
+ expect(hasEdge("calls", "consume")).toBe(true);
+ expect(hasEdge("instantiates", "Box")).toBe(true);
+ expect(hasEdge("instantiates", "Order")).toBe(true);
+ expect(hasEdge("instantiates", "User")).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 === "User::Greet",
+ )!;
+ expect(
+ result.edges.some(
+ (e) => e.kind === "contains" && e.source === userClass.id && e.target === greet.id,
+ ),
+ ).toBe(true);
+ });
+
+ it("emits `type_of` edge for struct fields", () => {
+ const nameField = result.nodes.find(
+ (n) => n.kind === "property" && n.name === "Name",
+ )!;
+ expect(nameField).toBeDefined();
+ expect(
+ result.edges.some(
+ (e) => e.kind === "type_of" && e.source === nameField.id && e.targetName === "string",
+ ),
+ ).toBe(true);
+ });
+
+ it("handles generic type parameters", () => {
+ const box = node("class", "Box")!;
+ expect(box.typeParameters).toEqual(["T"]);
+
+ const makeBox = node("function", "makeBox")!;
+ expect(makeBox.typeParameters).toEqual(["T"]);
+
+ // A declaration can bind several names to one constraint: `[K, V any]`.
+ const convert = node("function", "Convert")!;
+ expect(convert).toBeDefined();
+ expect(convert.typeParameters).toEqual(["K", "V"]);
+ });
+
+ it("binds a method to a receiver type declared later in the file", () => {
+ const cache = node("class", "Cache")!;
+ expect(cache).toBeDefined();
+
+ const get = result.nodes.find(
+ (n) => n.kind === "method" && n.qualifiedName === "Cache::Get",
+ )!;
+ expect(get).toBeDefined();
+
+ // The method is contained by the receiver type, not the file.
+ const parents = result.edges.filter(
+ (e) => e.kind === "contains" && e.target === get.id,
+ );
+ expect(parents).toHaveLength(1);
+ expect(parents[0].source).toBe(cache.id);
+ });
+});
\ No newline at end of file
diff --git a/src/graph/__tests__/fixtures/sample.go b/src/graph/__tests__/fixtures/sample.go
new file mode 100644
index 00000000..e39d4089
--- /dev/null
+++ b/src/graph/__tests__/fixtures/sample.go
@@ -0,0 +1,80 @@
+// Package main is a sample Go file for testing the code-graph extractor.
+
+package main
+
+import (
+ "fmt"
+ "strings"
+)
+
+// User represents a user in the system.
+type User struct {
+ Name string
+ Age int
+}
+
+type Role string
+
+const (
+ RoleAdmin Role = "admin"
+ RoleMember Role = "member"
+)
+
+var globalFlag = true
+var count, total = 0, 1
+
+// Greeter is an interface for greeting.
+type Greeter interface {
+ Greet() string
+}
+
+func (u *User) Greet() string {
+ return fmt.Sprintf("Hello, my name is %s", u.Name)
+}
+
+func processOrder(order *Order) {
+ fmt.Println("Processing order:", order.ID)
+}
+
+type Order struct {
+ ID int
+ Items []string
+}
+
+func (o *Order) AddItem(item string) {
+ o.Items = append(o.Items, item)
+}
+
+type Box[T any] struct {
+ Value T
+}
+
+func makeBox[T any](val T) Box[T] {
+ return Box[T]{Value: val}
+}
+
+func consume(val any) {}
+
+func CreateUser(name string) *User {
+ u := &User{Name: name, Age: 30}
+ processOrder(&Order{ID: 1})
+ u.Greet()
+ consume(makeBox(42))
+ return u
+}
+
+type Repo[T any] interface {
+ Get() T
+}
+
+func (c *Cache) Get(key string) string {
+ return c.items[key]
+}
+
+func Convert[K, V any](k K, v V) (K, V) {
+ return k, v
+}
+
+type Cache struct {
+ items map[string]string
+}
diff --git a/src/graph/extraction/grammars.ts b/src/graph/extraction/grammars.ts
index 13cb6e59..a775b8d5 100644
--- a/src/graph/extraction/grammars.ts
+++ b/src/graph/extraction/grammars.ts
@@ -31,6 +31,7 @@ const WASM_GRAMMAR_FILES: Partial> = {
python: "tree-sitter-python.wasm",
rust: "tree-sitter-rust.wasm",
csharp: "tree-sitter-c-sharp.wasm",
+ go: "tree-sitter-go.wasm",
};
let grammarHashCache: string | null = null;
@@ -65,6 +66,7 @@ const EXTENSION_MAP: Record = {
".py": "python",
".rs": "rust",
".cs": "csharp",
+ ".go": "go",
};
/** Glob pattern for every extension registered above. */
diff --git a/src/graph/extraction/languages/go.ts b/src/graph/extraction/languages/go.ts
new file mode 100644
index 00000000..67eda966
--- /dev/null
+++ b/src/graph/extraction/languages/go.ts
@@ -0,0 +1,564 @@
+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 FUNCTION_TYPES = new Set(["function_declaration"]);
+const METHOD_TYPES = new Set(["method_declaration"]);
+const TYPE_DECL_TYPES = new Set(["type_declaration", "type_spec", "type_alias"]);
+const CONST_TYPES = new Set(["const_declaration"]);
+const VAR_TYPES = new Set(["var_declaration", "var_spec", "var_spec_list"]);
+const CALL_TYPES = new Set(["call_expression"]);
+const IMPORT_TYPES = new Set(["import_declaration"]);
+const COMPOSITE_LITERAL_TYPES = new Set(["composite_literal"]);
+
+function getGoDocstring(node: TSNode, source: string): string | undefined {
+ const docs: string[] = [];
+ let cur: TSNode | null = node.previousNamedSibling;
+ while (cur && cur.type === "comment") {
+ const text = getNodeText(cur, source).trim();
+ if (text.startsWith("//")) {
+ docs.unshift(text.slice(2).trim());
+ }
+ cur = cur.previousNamedSibling;
+ }
+ if (docs.length === 0 && node.parent) {
+ cur = node.parent.previousNamedSibling;
+ while (cur && cur.type === "comment") {
+ const text = getNodeText(cur, source).trim();
+ if (text.startsWith("//")) {
+ docs.unshift(text.slice(2).trim());
+ }
+ cur = cur.previousNamedSibling;
+ }
+ }
+ return docs.length > 0 ? docs.join("\n") : undefined;
+}
+
+function isExported(name: string): boolean {
+ return name.length > 0 && name[0] === name[0].toUpperCase();
+}
+
+function visibilityOf(node: TSNode, source: string): ExtractedNode["visibility"] {
+ const name = nameOf(node, source);
+ return name && isExported(name) ? "public" : "private";
+}
+
+class GoWalker {
+ private readonly nodes: ExtractedNode[] = [];
+ private readonly edges: ExtractedEdge[] = [];
+ private scopeStack: string[] = [];
+ private readonly deferredMethods: { node: TSNode; stack: 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);
+
+ // Pass 1: extract everything except methods, which are deferred so a
+ // method can bind to a receiver type declared later in the same file.
+ for (const child of root.namedChildren) {
+ if (child.type === "method_declaration") {
+ this.deferredMethods.push({ node: child, stack: [...this.scopeStack] });
+ } else {
+ this.visit(child);
+ }
+ }
+
+ // Pass 2: extract all methods, binding them to their receiver types.
+ for (const { node, stack } of this.deferredMethods) {
+ const oldStack = this.scopeStack;
+ this.scopeStack = stack;
+ this.extractMethod(node);
+ this.scopeStack = oldStack;
+ }
+
+ this.scopeStack.pop();
+
+ return { nodes: this.nodes, edges: this.edges };
+ }
+
+ private visit(node: TSNode): void {
+ const type = node.type;
+
+ if (FUNCTION_TYPES.has(type)) return this.extractFunction(node);
+ if (METHOD_TYPES.has(type)) return this.extractMethod(node);
+ if (TYPE_DECL_TYPES.has(type)) return this.extractType(node);
+ if (CONST_TYPES.has(type)) return this.extractConst(node);
+ if (VAR_TYPES.has(type)) return this.extractVar(node);
+ if (IMPORT_TYPES.has(type)) return this.extractImport(node);
+
+ 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);
+ const newNode = {
+ 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,
+ docstring: getGoDocstring(node, this.source),
+ isExported: isExported(name),
+ ...extra,
+ };
+ this.nodes.push(newNode);
+
+ 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 extractFunction(node: TSNode): void {
+ const name = nameOf(node, this.source);
+ if (!name) return;
+
+ const id = this.createNode("function", name, node, {
+ signature: signatureOf(node, this.source),
+ returnType: returnTypeOf(node, this.source),
+ typeParameters: typeParametersOf(node, this.source),
+ });
+ if (!id) return;
+
+ const body = getChildByField(node, "body");
+ if (body) this.walkBody(body, id);
+ }
+
+ private extractMethod(node: TSNode): void {
+ const name = nameOf(node, this.source);
+ if (!name) return;
+
+ const receiver = getChildByField(node, "receiver");
+ let receiverType: string | undefined;
+ if (receiver) {
+ for (const param of receiver.namedChildren) {
+ if (param.type === "parameter_declaration") {
+ const typeNode = getChildByField(param, "type");
+ if (typeNode) {
+ receiverType = baseTypeName(typeNode, this.source);
+ break;
+ }
+ }
+ }
+ }
+
+ // Go methods live outside their type in the source. When the receiver type
+ // is in this file, the method's containment parent and qualified name come
+ // from that type (single containment chain, receiver-stable identity). When
+ // it is not (another file / an unknown type), fall back to the file scope.
+ const receiverNode = receiverType
+ ? this.nodes.find(
+ (n) =>
+ n.name === receiverType &&
+ (n.kind === "class" || n.kind === "interface"),
+ )
+ : undefined;
+ if (receiverNode) this.scopeStack.push(receiverNode.id);
+ const id = this.createNode("method", name, node, {
+ signature: signatureOf(node, this.source),
+ returnType: returnTypeOf(node, this.source),
+ visibility: visibilityOf(node, this.source),
+ typeParameters: typeParametersOf(node, this.source),
+ });
+ if (receiverNode) this.scopeStack.pop();
+ if (!id) return;
+
+ const body = getChildByField(node, "body");
+ if (body) this.walkBody(body, id);
+ }
+
+ private extractType(node: TSNode): void {
+ if (node.type === "type_alias") {
+ const nameNode = getChildByField(node, "name");
+ if (nameNode) {
+ const name = getNodeText(nameNode, this.source);
+ this.createNode("type_alias", name, node);
+ }
+ return;
+ }
+
+ if (node.type === "type_spec" || node.type === "type_declaration") {
+ for (const child of node.namedChildren) {
+ if (child.type === "type_spec") {
+ this.extractTypeSpec(child);
+ }
+ }
+ return;
+ }
+ }
+
+ private extractTypeSpec(node: TSNode): void {
+ const nameNode = getChildByField(node, "name");
+ const typeNode = getChildByField(node, "type");
+ if (!nameNode || !typeNode) return;
+
+ const name = getNodeText(nameNode, this.source);
+ const typeKind = typeNode.type;
+
+ if (typeKind === "struct_type") {
+ const id = this.createNode("class", name, node, {
+ visibility: visibilityOf(node, this.source),
+ typeParameters: typeParametersOf(node, this.source),
+ });
+ if (!id) return;
+
+ this.extractStructBody(typeNode, id);
+ } else if (typeKind === "interface_type") {
+ const id = this.createNode("interface", name, node, {
+ visibility: visibilityOf(node, this.source),
+ typeParameters: typeParametersOf(node, this.source),
+ });
+ if (!id) return;
+
+ this.extractInterfaceBody(typeNode, id);
+ } else {
+ this.createNode("type_alias", name, node, {
+ signature: getNodeText(typeNode, this.source).slice(0, 200),
+ });
+ }
+ }
+
+ private extractStructBody(structNode: TSNode, ownerId: string): void {
+ // struct_type has field_declaration_list as a direct named child, not a "body" field
+ const fieldList = structNode.namedChildren.find(c => c.type === "field_declaration_list");
+ if (!fieldList) return;
+
+ this.scopeStack.push(ownerId);
+ for (const member of fieldList.namedChildren) {
+ if (member.type === "field_declaration") {
+ const names = member.namedChildren.filter(c => c.type === "field_identifier");
+ const typeNode = getChildByField(member, "type");
+ for (const nameNode of names) {
+ const fieldName = getNodeText(nameNode, this.source);
+ const id = this.createNode("property", fieldName, member, {
+ visibility: visibilityOf(member, this.source),
+ });
+ if (id && typeNode) {
+ const fieldTypeName = baseTypeName(typeNode, this.source);
+ if (fieldTypeName) {
+ this.addRef(id, fieldTypeName, "type_of", member);
+ }
+ }
+ }
+ }
+ }
+ this.scopeStack.pop();
+ }
+
+ private extractInterfaceBody(interfaceNode: TSNode, ownerId: string): void {
+ this.scopeStack.push(ownerId);
+ for (const member of interfaceNode.namedChildren) {
+ if (member.type === "method_elem" || member.type === "method_spec") {
+ const nameNode = getChildByField(member, "name");
+ if (nameNode) {
+ const name = getNodeText(nameNode, this.source);
+ this.createNode("method", name, member, {
+ signature: signatureOf(member, this.source),
+ returnType: returnTypeOf(member, this.source),
+ });
+ }
+ } else if (member.type === "type_elem") {
+ for (const typeChild of member.namedChildren) {
+ const typeName = baseTypeName(typeChild, this.source);
+ if (typeName) {
+ this.addRef(ownerId, typeName, "implements", member);
+ }
+ }
+ }
+ }
+ this.scopeStack.pop();
+ }
+
+ private extractConst(node: TSNode): void {
+ for (const spec of node.namedChildren) {
+ if (spec.type === "const_spec") {
+ for (const nameNode of spec.namedChildren) {
+ if (nameNode.type === "identifier") {
+ const name = getNodeText(nameNode, this.source);
+ const valueNode = getChildByField(spec, "value");
+ this.createNode("constant", name, spec, {
+ signature: valueNode ? getNodeText(valueNode, this.source).slice(0, 200) : undefined,
+ visibility: visibilityOf(spec, this.source),
+ });
+ }
+ }
+ }
+ }
+ }
+
+ private extractVar(node: TSNode): void {
+ if (node.type === "var_spec_list") {
+ for (const spec of node.namedChildren) {
+ if (spec.type === "var_spec") this.extractVarSpec(spec);
+ }
+ return;
+ }
+ if (node.type === "var_spec") return this.extractVarSpec(node);
+ if (node.type === "var_declaration") {
+ for (const spec of node.namedChildren) {
+ if (spec.type === "var_spec") this.extractVarSpec(spec);
+ }
+ }
+ }
+
+ private extractVarSpec(node: TSNode): void {
+ const typeNode = getChildByField(node, "type");
+ const valueNode = getChildByField(node, "value");
+
+ for (const nameNode of node.namedChildren) {
+ if (nameNode.type !== "identifier") continue;
+ const name = getNodeText(nameNode, this.source);
+ this.createNode("variable", name, node, {
+ signature: valueNode
+ ? getNodeText(valueNode, this.source).slice(0, 200)
+ : typeNode
+ ? getNodeText(typeNode, this.source)
+ : undefined,
+ visibility: visibilityOf(node, this.source),
+ });
+ }
+ }
+
+ private extractImport(node: TSNode): void {
+ const fileId = this.scopeStack[0];
+ if (!fileId) return;
+
+ for (const spec of node.namedChildren) {
+ if (spec.type === "import_spec" || spec.type === "import_spec_list") {
+ if (spec.type === "import_spec_list") {
+ for (const inner of spec.namedChildren) {
+ if (inner.type === "import_spec") this.processImportSpec(fileId, inner);
+ }
+ } else {
+ this.processImportSpec(fileId, spec);
+ }
+ }
+ }
+ }
+
+ private processImportSpec(fileId: string, spec: TSNode): void {
+ const pathNode = getChildByField(spec, "path");
+ if (!pathNode) return;
+ const moduleSpecifier = getNodeText(pathNode, this.source).replace(/^"|"$/g, '');
+ const nameNode = getChildByField(spec, "name");
+ let localName = "";
+ if (nameNode) {
+ localName = getNodeText(nameNode, this.source);
+ }
+ this.addRef(fileId, moduleSpecifier, "imports", spec, {
+ bindings: [{ localName: localName || moduleSpecifier.split("/").pop() || moduleSpecifier, importedName: "*" }],
+ });
+ }
+
+ private walkBody(body: TSNode, ownerId: string): void {
+ const type = body.type;
+
+ if (CALL_TYPES.has(type)) {
+ this.extractCall(body, ownerId);
+ } else if (COMPOSITE_LITERAL_TYPES.has(type)) {
+ this.extractCompositeLiteral(body, ownerId);
+ } else if (FUNCTION_TYPES.has(type) || METHOD_TYPES.has(type)) {
+ 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");
+ let calleeName = "";
+ if (fn) {
+ if (fn.type === "selector_expression") {
+ const field = getChildByField(fn, "field");
+ const operand = getChildByField(fn, "operand");
+ const methodName = field ? getNodeText(field, this.source) : "";
+ const pkgName = operand ? getNodeText(operand, this.source) : "";
+ calleeName = pkgName && methodName ? `${pkgName}.${methodName}` : methodName;
+ } else if (fn.type === "identifier") {
+ calleeName = getNodeText(fn, this.source);
+ } else {
+ calleeName = getNodeText(fn, this.source);
+ }
+ }
+ if (calleeName) this.addRef(ownerId, calleeName, "calls", node);
+
+ const args = getChildByField(node, "arguments");
+ if (args) for (const child of args.namedChildren) this.walkBody(child, ownerId);
+ }
+
+ private extractCompositeLiteral(node: TSNode, ownerId: string): void {
+ const typeNode = getChildByField(node, "type");
+ if (typeNode) {
+ const structName = baseTypeName(typeNode, this.source);
+ if (structName) this.addRef(ownerId, structName, "instantiates", node);
+ }
+ const body = getChildByField(node, "body");
+ if (body) {
+ for (const child of body.namedChildren) this.walkBody(child, ownerId);
+ }
+ }
+
+ 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,
+ });
+ }
+}
+
+function nameOf(node: TSNode, source: string): string {
+ const nameNode = getChildByField(node, "name");
+ return nameNode ? getNodeText(nameNode, source) : "";
+}
+
+function signatureOf(node: TSNode, source: string): string | undefined {
+ const params = getChildByField(node, "parameters");
+ if (!params) return undefined;
+ let sig = getNodeText(params, source);
+ const ret = getChildByField(node, "result");
+ if (ret) sig += " " + getNodeText(ret, source);
+ return sig;
+}
+
+function returnTypeOf(node: TSNode, source: string): string | undefined {
+ const ret = getChildByField(node, "result");
+ if (!ret) return undefined;
+ return getNodeText(ret, source);
+}
+
+function typeParametersOf(node: TSNode, source: string): string[] | undefined {
+ const typeParams = getChildByField(node, "type_parameters");
+ if (!typeParams) return undefined;
+ const params: string[] = [];
+ for (let i = 0; i < typeParams.namedChildCount; i++) {
+ const child = typeParams.namedChild(i);
+ if (!child) continue;
+ // `type_parameter_declaration` (tree-sitter-go v0.25) / `parameter_declaration`
+ // (older prebuilt wasm). A single declaration can bind several names to one
+ // constraint: `T, U any`.
+ if (
+ child.type === "type_parameter_declaration" ||
+ child.type === "parameter_declaration"
+ ) {
+ for (const nameNode of child.namedChildren) {
+ if (nameNode.type === "identifier") {
+ params.push(getNodeText(nameNode, source));
+ }
+ }
+ } else {
+ params.push(getNodeText(child, source));
+ }
+ }
+ return params.length > 0 ? params : undefined;
+}
+
+function baseTypeName(node: TSNode, source: string): string | undefined {
+ if (node.type === "generic_type") {
+ const base = node.namedChild(0);
+ return base ? baseTypeName(base, source) : undefined;
+ }
+ if (node.type === "qualified_type") {
+ const name = getChildByField(node, "name");
+ return name ? getNodeText(name, source) : undefined;
+ }
+ if (node.type === "pointer_type") {
+ for (let i = node.namedChildCount - 1; i >= 0; i--) {
+ const child = node.namedChild(i);
+ if (child) return baseTypeName(child, source);
+ }
+ return undefined;
+ }
+ return getNodeText(node, source);
+}
+
+function baseName(filePath: string): string {
+ const normalized = filePath.replace(/\\/g, "/");
+ const slash = normalized.lastIndexOf("/");
+ return slash < 0 ? normalized : normalized.slice(slash + 1);
+}
+
+export const goExtractor: LanguageExtractor = {
+ language: "go",
+ fileExtensions: [".go"],
+ grammarWasm: "tree-sitter-go.wasm",
+ extract(tree: TSTree, filePath: string, source: string) {
+ return new GoWalker(filePath, source, "go").run(tree.rootNode);
+ },
+};
\ No newline at end of file
diff --git a/src/graph/extraction/languages/index.ts b/src/graph/extraction/languages/index.ts
index 16662522..9c59d27c 100644
--- a/src/graph/extraction/languages/index.ts
+++ b/src/graph/extraction/languages/index.ts
@@ -13,6 +13,7 @@ import { javascriptExtractor, jsxExtractor } from "./javascript.js";
import { pythonExtractor } from "./python.js";
import { rustExtractor } from "./rust.js";
import { csharpExtractor } from "./csharp.js";
+import { goExtractor } from "./go.js";
/** Registered extractors, keyed by the language id they emit. */
export const EXTRACTORS: Partial> = {
@@ -23,6 +24,7 @@ export const EXTRACTORS: Partial> = {
python: pythonExtractor,
rust: rustExtractor,
csharp: csharpExtractor,
+ go: goExtractor,
};
/** The extractor for a language, or undefined if unsupported in this release. */
diff --git a/src/graph/wasm/tree-sitter-go.wasm b/src/graph/wasm/tree-sitter-go.wasm
new file mode 100644
index 00000000..71a01bd8
Binary files /dev/null and b/src/graph/wasm/tree-sitter-go.wasm differ