Add a resolution-phase pass (goCrossFileMethodContainsEdges) that links a Go method to its same-named receiver type within the same package (= directory), so a method declared in a different file from its `type` is no longer orphaned from the struct. Runs before goImplementsEdges so cross-file methods also count toward interface satisfaction (#584). Adds a regression test + CHANGELOG entry. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
8d35931c3b
commit
2f50473aaa
@@ -69,6 +69,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|||||||
- TypeScript `.mts` and `.cts` module files are now indexed instead of being skipped. (#366)
|
- TypeScript `.mts` and `.cts` module files are now indexed instead of being skipped. (#366)
|
||||||
- JavaScript modules that wrap their code in an anonymous function — AMD/RequireJS, NetSuite SuiteScript, IIFE bundles — now have their inner functions and calls indexed, instead of the file coming up nearly empty. (#528)
|
- JavaScript modules that wrap their code in an anonymous function — AMD/RequireJS, NetSuite SuiteScript, IIFE bundles — now have their inner functions and calls indexed, instead of the file coming up nearly empty. (#528)
|
||||||
- Go methods declared on generic types (e.g. `func (s *Stack[T]) Push(...)`) are now correctly attached to their type, so callers, callees, and impact include them. (#583)
|
- Go methods declared on generic types (e.g. `func (s *Stack[T]) Push(...)`) are now correctly attached to their type, so callers, callees, and impact include them. (#583)
|
||||||
|
- Go methods now attach to their receiver type even when declared in a different file from the `type` itself — the idiomatic split where `type User struct{…}` lives in one file and `func (u *User) Save()` in another. Previously a cross-file method was orphaned from its struct, so the type's member list, callers/callees, and impact missed it; as a knock-on, a struct whose interface-satisfying methods are spread across files now also links to the interfaces it implements. (#583)
|
||||||
- Asking what a symbol impacts no longer drags in every unrelated sibling method of its class — impact now follows real dependencies instead of the structural "contains" relationship, keeping the result focused on what actually depends on the symbol. (#536)
|
- Asking what a symbol impacts no longer drags in every unrelated sibling method of its class — impact now follows real dependencies instead of the structural "contains" relationship, keeping the result focused on what actually depends on the symbol. (#536)
|
||||||
- CodeGraph's MCP server now answers an agent's `resources/list` and `prompts/list` probes with an empty list instead of an error, clearing the `-32601` messages some clients (opencode, Codex) logged on connect. (#621)
|
- CodeGraph's MCP server now answers an agent's `resources/list` and `prompts/list` probes with an empty list instead of an error, clearing the `-32601` messages some clients (opencode, Codex) logged on connect. (#621)
|
||||||
- Svelte and Vue components used through a barrel file — `export { default as Button } from './Button.svelte'` re-exported from an `index.ts` and imported elsewhere — are no longer falsely reported as having **0 callers**. CodeGraph now follows the default re-export all the way to the component and resolves the imports that `.svelte` / `.vue` files themselves use, so `codegraph_callers` and `codegraph_impact` see every place a component is used. This also covers components imported from another package in a workspace/monorepo (`@scope/ui/widgets`) and bare directory imports (`import { x } from './'`). Previously a live component consumed only through a barrel looked like dead code. Thanks @nakisen. (#629)
|
- Svelte and Vue components used through a barrel file — `export { default as Button } from './Button.svelte'` re-exported from an `index.ts` and imported elsewhere — are no longer falsely reported as having **0 callers**. CodeGraph now follows the default re-export all the way to the component and resolves the imports that `.svelte` / `.vue` files themselves use, so `codegraph_callers` and `codegraph_impact` see every place a component is used. This also covers components imported from another package in a workspace/monorepo (`@scope/ui/widgets`) and bare directory imports (`import { x } from './'`). Previously a live component consumed only through a barrel looked like dead code. Thanks @nakisen. (#629)
|
||||||
|
|||||||
@@ -903,6 +903,64 @@ def external_caller():
|
|||||||
expect(externalCalls).toHaveLength(0);
|
expect(externalCalls).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('attaches Go methods to their receiver type across files (#583, cross-file half)', async () => {
|
||||||
|
// In Go a type's methods are commonly declared in a different file from the
|
||||||
|
// `type` declaration (`type Box` in box.go, `func (b *Box) Get()` in
|
||||||
|
// box_methods.go). Extraction only attaches the struct→method `contains`
|
||||||
|
// edge when the type is in the SAME file (the owner lookup is file-scoped),
|
||||||
|
// so a cross-file method was orphaned from its struct — breaking member
|
||||||
|
// outlines and any callers/callees/impact traversal through `contains`. A
|
||||||
|
// resolution-phase pass now links them within the package (= directory).
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(tempDir, 'box.go'),
|
||||||
|
'package main\n\ntype Box struct{ v int }\n'
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(tempDir, 'box_methods.go'),
|
||||||
|
'package main\n\nfunc (b *Box) Get() int { return b.v }\nfunc (b *Box) Set(x int) { b.v = x }\n'
|
||||||
|
);
|
||||||
|
// Generic receiver declared cross-file too — exercises #583 half A
|
||||||
|
// (generic `*Stack[T]` receiver parsing) and half B (cross-file) together.
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(tempDir, 'stack.go'),
|
||||||
|
'package main\n\ntype Stack[T any] struct {\n\titems []T\n}\n'
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(tempDir, 'stack_push.go'),
|
||||||
|
'package main\n\nfunc (s *Stack[T]) Push(v T) { s.items = append(s.items, v) }\n'
|
||||||
|
);
|
||||||
|
// A same-named type in another package must NOT capture this package's
|
||||||
|
// methods — the link is scoped to the receiver type's own directory.
|
||||||
|
fs.mkdirSync(path.join(tempDir, 'other'));
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(tempDir, 'other', 'box.go'),
|
||||||
|
'package other\n\ntype Box struct{ w int }\n'
|
||||||
|
);
|
||||||
|
|
||||||
|
cg = await CodeGraph.init(tempDir, { index: true });
|
||||||
|
|
||||||
|
const methodsOf = (typeName: string, file: string): string[] => {
|
||||||
|
const node = cg
|
||||||
|
.getNodesByKind('struct')
|
||||||
|
.find((n) => n.name === typeName && n.filePath.replace(/\\/g, '/') === file);
|
||||||
|
expect(node, `${typeName} @ ${file}`).toBeDefined();
|
||||||
|
return cg
|
||||||
|
.getOutgoingEdges(node!.id)
|
||||||
|
.filter((e) => e.kind === 'contains')
|
||||||
|
.map((e) => cg.getNode(e.target))
|
||||||
|
.filter((n) => !!n && n.kind === 'method')
|
||||||
|
.map((n) => n!.name)
|
||||||
|
.sort();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cross-file (non-generic) methods now attach to their struct.
|
||||||
|
expect(methodsOf('Box', 'box.go')).toEqual(['Get', 'Set']);
|
||||||
|
// Generic + cross-file.
|
||||||
|
expect(methodsOf('Stack', 'stack.go')).toEqual(['Push']);
|
||||||
|
// Cross-package isolation: other/Box defines no methods of its own.
|
||||||
|
expect(methodsOf('Box', 'other/box.go')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it('TS type_alias object-shape members resolve method calls (#359)', async () => {
|
it('TS type_alias object-shape members resolve method calls (#359)', async () => {
|
||||||
// Pre-#359, `recorder.stop()` (recorder: RecorderHandle) attached
|
// Pre-#359, `recorder.stop()` (recorder: RecorderHandle) attached
|
||||||
// to `StdioMcpClient.stop` in a sibling directory via path-proximity
|
// to `StdioMcpClient.stop` in a sibling directory via path-proximity
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
* need receiver-type matching, deferred to Phase 3). All synthesized edges are
|
* need receiver-type matching, deferred to Phase 3). All synthesized edges are
|
||||||
* tagged `provenance:'heuristic'`. See docs/design/callback-edge-synthesis.md.
|
* tagged `provenance:'heuristic'`. See docs/design/callback-edge-synthesis.md.
|
||||||
*/
|
*/
|
||||||
import type { Edge, Node } from '../types';
|
import type { Edge, Node, NodeKind } from '../types';
|
||||||
import type { QueryBuilder } from '../db/queries';
|
import type { QueryBuilder } from '../db/queries';
|
||||||
import type { ResolutionContext } from './types';
|
import type { ResolutionContext } from './types';
|
||||||
import { isGeneratedFile } from '../extraction/generated-detection';
|
import { isGeneratedFile } from '../extraction/generated-detection';
|
||||||
@@ -534,6 +534,74 @@ function goImplementsEdges(queries: QueryBuilder): Edge[] {
|
|||||||
return edges;
|
return edges;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cross-file Go method → receiver-type `contains` edges. In Go a type's methods
|
||||||
|
* are commonly declared in a different file from the `type` declaration itself
|
||||||
|
* (`type User struct{…}` in `user.go`, `func (u *User) Save()` in
|
||||||
|
* `user_store.go`). Extraction attaches the struct→method `contains` edge only
|
||||||
|
* when the receiver type is in the SAME file — the owner lookup in
|
||||||
|
* `tree-sitter.ts` is scoped to the file being parsed — so a cross-file method
|
||||||
|
* is left orphaned from its type (it's still `contains`ed by its file, just not
|
||||||
|
* its struct). That breaks `codegraph_node` member outlines, any
|
||||||
|
* callers/callees/impact traversal that goes through the type's `contains`
|
||||||
|
* edges, and the {@link goImplementsEdges} method-set computation (which derives
|
||||||
|
* a struct's method set from those same edges, so it under-counts interfaces a
|
||||||
|
* cross-file struct satisfies).
|
||||||
|
*
|
||||||
|
* Go guarantees a method's receiver type is declared in the SAME PACKAGE as the
|
||||||
|
* method, and a Go package is a single directory — so this is a deterministic
|
||||||
|
* structural link, not a heuristic: find the same-named type in the method's own
|
||||||
|
* directory and add the missing `contains` edge (no `provenance: 'heuristic'`,
|
||||||
|
* matching the same-file edges extraction already emits). Skips methods that
|
||||||
|
* already have a type parent (the same-file case). (#583, cross-file half)
|
||||||
|
*/
|
||||||
|
function goCrossFileMethodContainsEdges(queries: QueryBuilder): Edge[] {
|
||||||
|
const edges: Edge[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const TYPE_KINDS = new Set<NodeKind>(['struct', 'class', 'interface', 'enum', 'type_alias']);
|
||||||
|
const dirOf = (p: string): string => {
|
||||||
|
const i = p.replace(/\\/g, '/').lastIndexOf('/');
|
||||||
|
return i >= 0 ? p.slice(0, i) : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const method of queries.getNodesByKind('method')) {
|
||||||
|
if (method.language !== 'go') continue;
|
||||||
|
// The receiver type is encoded in the method's qualifiedName as `Recv::name`
|
||||||
|
// (extraction sets `${receiverType}::${name}` for receiver methods).
|
||||||
|
const qn = method.qualifiedName;
|
||||||
|
if (!qn) continue;
|
||||||
|
const sep = qn.lastIndexOf('::');
|
||||||
|
if (sep <= 0) continue;
|
||||||
|
const receiver = qn.slice(0, sep);
|
||||||
|
if (!receiver) continue;
|
||||||
|
|
||||||
|
// Already attached to its type (same-file case handled at extraction)?
|
||||||
|
const hasTypeParent = queries
|
||||||
|
.getIncomingEdges(method.id, ['contains'])
|
||||||
|
.some((e) => {
|
||||||
|
const src = queries.getNodeById(e.source);
|
||||||
|
return src != null && TYPE_KINDS.has(src.kind);
|
||||||
|
});
|
||||||
|
if (hasTypeParent) continue;
|
||||||
|
|
||||||
|
// Find the receiver type in the SAME directory (= same Go package). Go forbids
|
||||||
|
// duplicate type names within a package, so a same-name same-dir match is
|
||||||
|
// unambiguous; scoping to the directory avoids linking to a same-named type
|
||||||
|
// in another package.
|
||||||
|
const dir = dirOf(method.filePath);
|
||||||
|
const owner = queries
|
||||||
|
.getNodesByName(receiver)
|
||||||
|
.find((n) => n.language === 'go' && TYPE_KINDS.has(n.kind) && dirOf(n.filePath) === dir);
|
||||||
|
if (!owner) continue;
|
||||||
|
|
||||||
|
const key = `${owner.id}>${method.id}`;
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
edges.push({ source: owner.id, target: method.id, kind: 'contains', line: method.startLine });
|
||||||
|
}
|
||||||
|
return edges;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Kotlin Multiplatform `expect`/`actual` linking. A `common` source set declares
|
* Kotlin Multiplatform `expect`/`actual` linking. A `common` source set declares
|
||||||
* `expect fun foo()` / `expect class Bar`; each platform source set (jvm, native,
|
* `expect fun foo()` / `expect class Bar`; each platform source set (jvm, native,
|
||||||
@@ -1585,7 +1653,15 @@ function svelteKitLoadEdges(ctx: ResolutionContext): Edge[] {
|
|||||||
* Returns the count added. Never throws into indexing — callers wrap in try/catch.
|
* Returns the count added. Never throws into indexing — callers wrap in try/catch.
|
||||||
*/
|
*/
|
||||||
export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionContext): number {
|
export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionContext): number {
|
||||||
// Go implicit `implements` edges must be synthesized AND persisted first: the
|
// Cross-file Go method→type `contains` edges must be synthesized AND persisted
|
||||||
|
// FIRST: a method declared in a different file from its receiver type is
|
||||||
|
// otherwise orphaned from the struct, and goImplementsEdges (next) derives a
|
||||||
|
// struct's method set from its `contains` edges — so without this it would
|
||||||
|
// under-count the interfaces a cross-file struct satisfies. (#583)
|
||||||
|
const goMethodContains = goCrossFileMethodContainsEdges(queries);
|
||||||
|
if (goMethodContains.length > 0) queries.insertEdges(goMethodContains);
|
||||||
|
|
||||||
|
// Go implicit `implements` edges must be synthesized AND persisted next: the
|
||||||
// interface-dispatch bridge below reads `implements` edges from the DB, and
|
// interface-dispatch bridge below reads `implements` edges from the DB, and
|
||||||
// Go has none statically. (Other languages already have static implements
|
// Go has none statically. (Other languages already have static implements
|
||||||
// edges from extraction, so they don't need this pre-pass.)
|
// edges from extraction, so they don't need this pre-pass.)
|
||||||
@@ -1641,5 +1717,5 @@ export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionCo
|
|||||||
merged.push(e);
|
merged.push(e);
|
||||||
}
|
}
|
||||||
if (merged.length > 0) queries.insertEdges(merged);
|
if (merged.length > 0) queries.insertEdges(merged);
|
||||||
return merged.length + goImpl.length;
|
return merged.length + goImpl.length + goMethodContains.length;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user