Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
44 changes: 44 additions & 0 deletions __tests__/resolution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -853,6 +853,50 @@ 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, '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 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(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([]);
});

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];
},
};
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'] as const) {
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 Include Go type_alias owners when linking receiver methods

For valid Go defined types whose underlying type is not a struct/interface, e.g. type Status string with func (s Status) String() string in another file, the extractor creates the owner as a type_alias node, but this owner scan only considers struct/class/enum/trait. Those receiver methods therefore remain orphaned even though they are declared on a matching type in the same package; include Go type_alias nodes in the owner map so named non-struct types get the same contains edge.

Useful? React with 👍 / 👎.

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