Skip to content
Open
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
28 changes: 28 additions & 0 deletions __tests__/extraction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -657,6 +657,34 @@ func (s *Service) GetUser(id string) (*User, error) {
expect(methodNode).toBeDefined();
expect(methodNode?.name).toBe('GetUser');
});

it('should attach generic receiver methods to their Go type', () => {
const code = `
package main

type Stack[T any] struct {
items []T
}

func (s *Stack[T]) Push(v T) {
s.items = append(s.items, v)
}
`;
const result = extractFromSource('stack.go', code);

const stack = result.nodes.find((n) => n.kind === 'struct' && n.name === 'Stack');
const push = result.nodes.find((n) => n.kind === 'method' && n.name === 'Push');
expect(stack).toBeDefined();
expect(push).toBeDefined();
expect(push?.qualifiedName).toContain('Stack::Push');
expect(result.edges).toContainEqual(
expect.objectContaining({
source: stack!.id,
target: push!.id,
kind: 'contains',
})
);
});
});

describe('Rust Extraction', () => {
Expand Down
88 changes: 88 additions & 0 deletions __tests__/resolution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -853,6 +853,94 @@ func UseAliased() {
expect(target?.filePath.replace(/\\/g, '/')).toBe('pkgb/lib.go');
});

it('links Go receiver methods to owner types across files in the same package (#583)', async () => {
fs.writeFileSync(
path.join(tempDir, 'go.mod'),
'module github.com/example/myproject\n\ngo 1.21\n'
);
fs.mkdirSync(path.join(tempDir, 'repro'));
fs.mkdirSync(path.join(tempDir, 'other'));
fs.writeFileSync(
path.join(tempDir, 'repro', 'widget.go'),
'package repro\n\ntype Widget struct{ name string }\nfunc (w *Widget) Foo() string { return "foo:" + w.name }\n'
);
fs.writeFileSync(
path.join(tempDir, 'repro', 'widget_extra.go'),
'package repro\n\nfunc (w *Widget) Bar() string { return "bar:" + w.name }\n'
);
fs.writeFileSync(
path.join(tempDir, 'repro', 'status.go'),
'package repro\n\ntype Status string\n'
);
fs.writeFileSync(
path.join(tempDir, 'repro', 'status_extra.go'),
'package repro\n\nfunc (s Status) String() string { return string(s) }\n'
);
fs.writeFileSync(
path.join(tempDir, 'other', 'widget.go'),
'package other\n\ntype Widget struct{ id int }\n'
);

cg = await CodeGraph.init(tempDir, { index: true });

const widget = cg.getNodesByKind('struct').find((n) => n.name === 'Widget' && n.filePath === 'repro/widget.go');
const otherWidget = cg.getNodesByKind('struct').find((n) => n.name === 'Widget' && n.filePath === 'other/widget.go');
const status = cg.getNodesByKind('type_alias').find((n) => n.name === 'Status' && n.filePath === 'repro/status.go');
const methods = cg.getNodesByKind('method').filter((n) => n.qualifiedName.startsWith('Widget::'));
const methodNames = methods.map((n) => n.name).sort();
expect(widget).toBeDefined();
expect(otherWidget).toBeDefined();
expect(status).toBeDefined();
expect(methodNames).toEqual(['Bar', 'Foo']);

const containedMethodNames = cg.getOutgoingEdges(widget!.id)
.filter((edge) => edge.kind === 'contains')
.map((edge) => cg.getNode(edge.target)?.name)
.filter(Boolean)
.sort();
expect(containedMethodNames).toEqual(['Bar', 'Foo']);

const otherContainedMethodNames = cg.getOutgoingEdges(otherWidget!.id)
.filter((edge) => edge.kind === 'contains')
.map((edge) => cg.getNode(edge.target)?.name)
.filter(Boolean);
expect(otherContainedMethodNames).toEqual([]);

const statusContainedMethodNames = cg.getOutgoingEdges(status!.id)
.filter((edge) => edge.kind === 'contains')
.map((edge) => cg.getNode(edge.target)?.name)
.filter(Boolean);
expect(statusContainedMethodNames).toEqual(['String']);
});

it('links Go receiver methods after indexing selected files (#583)', async () => {
fs.writeFileSync(
path.join(tempDir, 'go.mod'),
'module github.com/example/myproject\n\ngo 1.21\n'
);
fs.mkdirSync(path.join(tempDir, 'repro'));
fs.writeFileSync(
path.join(tempDir, 'repro', 'widget.go'),
'package repro\n\ntype Widget struct{ name string }\n'
);
fs.writeFileSync(
path.join(tempDir, 'repro', 'widget_extra.go'),
'package repro\n\nfunc (w *Widget) Bar() string { return "bar:" + w.name }\n'
);

cg = CodeGraph.initSync(tempDir);
const result = await cg.indexFiles(['repro/widget.go', 'repro/widget_extra.go']);
expect(result.success).toBe(true);

const widget = cg.getNodesByKind('struct').find((n) => n.name === 'Widget' && n.filePath === 'repro/widget.go');
expect(widget).toBeDefined();
const containedMethodNames = cg.getOutgoingEdges(widget!.id)
.filter((edge) => edge.kind === 'contains')
.map((edge) => cg.getNode(edge.target)?.name)
.filter(Boolean);
expect(containedMethodNames).toEqual(['Bar']);
});

it('TS type_alias object-shape members resolve method calls (#359)', async () => {
// Pre-#359, `recorder.stop()` (recorder: RecorderHandle) attached
// to `StdioMcpClient.stop` in a sibling directory via path-proximity
Expand Down
5 changes: 3 additions & 2 deletions src/extraction/languages/go.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,9 @@ export const goExtractor: LanguageExtractor = {
if (!receiver) return undefined;
// Find the type identifier inside the receiver
const text = getNodeText(receiver, source);
// Extract type name from patterns like "(sl *Type)", "(sl Type)", "(*Type)", "(Type)"
const match = text.match(/\*?\s*([A-Za-z_][A-Za-z0-9_]*)\s*\)/);
// Extract type name from patterns like "(sl *Type)", "(sl Type)",
// "(*Type)", "(Type)", and generic receivers like "(s *Stack[T])".
const match = text.match(/\(\s*(?:[A-Za-z_][A-Za-z0-9_]*\s+)?\*?\s*([A-Za-z_][A-Za-z0-9_]*)/);
return match?.[1];
},
};
7 changes: 6 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -397,7 +397,12 @@ export class CodeGraph {
return { success: false, filesIndexed: 0, filesSkipped: 0, filesErrored: 0, nodesCreated: 0, edgesCreated: 0, errors: [{ message: 'Could not acquire file lock - another process may be indexing', severity: 'error' as const }], durationMs: 0 };
}
try {
return this.orchestrator.indexFiles(filePaths);
const result = await this.orchestrator.indexFiles(filePaths);
if (result.success && result.filesIndexed > 0) {
this.resolver.initialize();
this.resolver.runPostExtract();
}
return result;
} finally {
this.fileLock.release();
}
Expand Down
63 changes: 63 additions & 0 deletions src/resolution/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,10 +256,73 @@ export class ReferenceResolver {
});
}
}
updated += this.linkGoReceiverMethodOwners();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Run Go owner linking for indexFiles

When callers use CodeGraph.indexFiles() to index a selected set of Go files, this new owner-linking pass never runs because it is only wired through runPostExtract() (called by indexAll/sync, while src/index.ts:392-400 returns directly from orchestrator.indexFiles). In that context, receiver methods declared in a different indexed file from their type remain without the synthesized contains edge, so the fix behaves differently depending on which public indexing API was used.

Useful? React with 👍 / 👎.

if (updated > 0) this.clearCaches();
return updated;
}

private linkGoReceiverMethodOwners(): number {
const methods = this.queries.getNodesByKind('method')
.filter((node) => node.language === 'go' && node.qualifiedName.includes('::'));
if (methods.length === 0) return 0;

const ownersByName = new Map<string, Node[]>();
for (const kind of ['struct', 'class', 'enum', 'trait', 'type_alias'] as const) {
for (const owner of this.queries.getNodesByKind(kind)) {
if (owner.language !== 'go') continue;
const existing = ownersByName.get(owner.name) ?? [];
existing.push(owner);
ownersByName.set(owner.name, existing);
}
}

const packageNameCache = new Map<string, string | null>();
const packageName = (filePath: string): string | null => {
if (packageNameCache.has(filePath)) return packageNameCache.get(filePath)!;
const source = this.context.readFile(filePath);
const match = source?.match(/^\s*package\s+([A-Za-z_][A-Za-z0-9_]*)/m);
const name = match?.[1] ?? null;
packageNameCache.set(filePath, name);
return name;
};

const edges: Edge[] = [];
for (const method of methods) {
const receiverType = method.qualifiedName.split('::', 1)[0];
if (!receiverType) continue;

const alreadyOwned = this.queries.getIncomingEdges(method.id, ['contains'])
.some((edge) => {
const source = this.queries.getNodeById(edge.source);
return source && source.language === 'go' && source.name === receiverType;
});
if (alreadyOwned) continue;

const methodDir = path.dirname(method.filePath);
const methodPackage = packageName(method.filePath);
if (!methodPackage) continue;

const owner = (ownersByName.get(receiverType) ?? []).find((candidate) =>
path.dirname(candidate.filePath) === methodDir &&
packageName(candidate.filePath) === methodPackage
);
if (!owner) continue;

edges.push({
source: owner.id,
target: method.id,
kind: 'contains',
provenance: 'heuristic',
metadata: { synthesizedBy: 'go-receiver-owner' },
});
}

if (edges.length > 0) {
this.queries.insertEdges(edges);
}
return edges.length;
}

/**
* Pre-build lightweight caches for resolution.
* Node lookups are now handled by indexed SQLite queries instead of
Expand Down