* fix(go): resolve chained factory-function calls New().Method() (#750) A Go call through a chained factory function — `New().Method()`, `With(cfg).Build()` — dropped the receiver to a bare method name, which then attached to a same-named method on an unrelated type (a wrong edge) or didn't resolve. Ports the #645/#608 mechanism for Go's bare-factory receivers: - Part 1: capture Go return types; a pointer `*Foo` -> `Foo`, a multi-return `(*Foo, error)` -> its first result, qualified `pkg.Foo` -> `Foo`. - Part 2: encode a bare-factory chain (`New().Method`), gated to an `identifier` receiver so instance chains (`obj.Method().Other()`) keep bare-name. - Part 3: matchDottedCallChain bare-inner Go branch looks up the FUNCTION's return type, then resolves+validates the method on it. Wired into the conformance pass so a method promoted from an embedded struct (`type Widget struct{ Base }` -> the existing `extends` edge) resolves. FALLBACK: when the inner isn't a resolvable function (a package-level VARIABLE holding a function value, e.g. gin's `engine()`), fall back to bare-name so the edge isn't dropped. Validated: synthetic decoy + args + multi-return + embedded-conformance + absent safety tests (4/4); full suite green. Real-repo A/B on gin (99 .go): pre-fallback -40 = 25 wrong self-loops removed (good) + 15 correct `Engine::ServeHTTP` dropped (gin's ginS variable-factory `engine()`); the fallback recovers the 15. gin A/B re-confirm with the fallback is PENDING (local index flakiness, not a code issue). EXTRACTION_VERSION 11 -> 12. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(go): stop the chained-call fallback from looping the batched resolver The Go variable-inner fallback (for chains like `engine().ServeHTTP()` whose inner is a package-level var, not a factory function) resolved the method via a synthetic bare-name ref and propagated THAT ref as `.original`. Its `referenceName` was the bare `ServeHTTP`, not the stored `engine().ServeHTTP`, so `resolveAndPersistBatched`'s keyed `deleteSpecificResolvedReferences` no-oped, the offset-0 batch never drained, and the loop re-resolved + re-inserted the same rows forever — a runaway that grew a 99-file repo (gin) to 5,050,206 edges / 1.4 GB before filling the disk. - name-matcher.ts: tie the bare-name match back to the original `ref` so the batch-cleanup delete matches the stored row and the loop drains. - index.ts: add a non-progress guard to resolveAndPersistBatched — if the unresolved_refs table doesn't shrink after a batch, stop instead of growing the graph without bound (defense-in-depth for any future keyed-delete mismatch). - resolution.test.ts: regression test for the variable-inner chain — asserts the fallback edge resolves AND the edge count stays bounded (no explosion). gin A/B (post-fix): db 5.8 MB / 3,699 calls edges; net-zero unique-edge diff vs main (the fallback recovers the dropped edges, adds no wrong ones). Full suite green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- 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
5805f01957
commit
ccced9e358
@@ -2617,4 +2617,115 @@ fn caller() { Foo::new().only_other(); }
|
||||
expect(callerNamesOf('Other::only_other')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Go chained factory-function call resolution (#645/#608 mechanism)', () => {
|
||||
function callerNamesOf(qualifiedName: string): string[] {
|
||||
const target = cg.getNodesByKind('method').find((n) => n.qualifiedName === qualifiedName);
|
||||
if (!target) return [];
|
||||
const names = cg
|
||||
.getIncomingEdges(target.id)
|
||||
.filter((e) => e.kind === 'calls')
|
||||
.map((e) => cg.getNode(e.source)?.name)
|
||||
.filter((n): n is string => !!n);
|
||||
return [...new Set(names)].sort();
|
||||
}
|
||||
|
||||
it('resolves New().Bar() via the factory return type (pointer), never a same-named decoy', async () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, 'main.go'),
|
||||
`package main
|
||||
type Aaa struct{}
|
||||
func (a *Aaa) Bar() {}
|
||||
type Foo struct{}
|
||||
func New() *Foo { return &Foo{} }
|
||||
func (f *Foo) Bar() {}
|
||||
func caller() { New().Bar() }
|
||||
`
|
||||
);
|
||||
cg = await CodeGraph.init(tempDir, { index: true });
|
||||
expect(callerNamesOf('Foo::Bar')).toEqual(['caller']);
|
||||
expect(callerNamesOf('Aaa::Bar')).toEqual([]);
|
||||
});
|
||||
|
||||
it('resolves an args chain and a multi-return factory — With(c).Build(), (*Foo, error)', async () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, 'main.go'),
|
||||
`package main
|
||||
type Config struct{}
|
||||
type Foo struct{}
|
||||
func With(c Config) (*Foo, error) { return &Foo{}, nil }
|
||||
func (f *Foo) Build() {}
|
||||
func caller() { With(Config{}).Build() }
|
||||
`
|
||||
);
|
||||
cg = await CodeGraph.init(tempDir, { index: true });
|
||||
expect(callerNamesOf('Foo::Build')).toEqual(['caller']);
|
||||
});
|
||||
|
||||
it('resolves a method provided by an embedded struct (via conformance)', async () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, 'main.go'),
|
||||
`package main
|
||||
type Base struct{}
|
||||
func (b *Base) Embedded() {}
|
||||
type Decoy struct{}
|
||||
func (d *Decoy) Embedded() {}
|
||||
type Widget struct{ Base }
|
||||
func NewWidget() *Widget { return &Widget{} }
|
||||
func caller() { NewWidget().Embedded() }
|
||||
`
|
||||
);
|
||||
cg = await CodeGraph.init(tempDir, { index: true });
|
||||
expect(callerNamesOf('Base::Embedded')).toEqual(['caller']);
|
||||
expect(callerNamesOf('Decoy::Embedded')).toEqual([]);
|
||||
});
|
||||
|
||||
it('creates NO edge when neither the type nor an embedded type has the method (silent miss)', async () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, 'main.go'),
|
||||
`package main
|
||||
type Foo struct{}
|
||||
func New() *Foo { return &Foo{} }
|
||||
type Other struct{}
|
||||
func (o *Other) OnlyOther() {}
|
||||
func caller() { New().OnlyOther() }
|
||||
`
|
||||
);
|
||||
cg = await CodeGraph.init(tempDir, { index: true });
|
||||
// Foo has no OnlyOther() — must not mis-attach to the same-named Other::OnlyOther.
|
||||
expect(callerNamesOf('Other::OnlyOther')).toEqual([]);
|
||||
});
|
||||
|
||||
it('falls back to bare-name resolution for a VARIABLE-inner chain without exploding the graph', async () => {
|
||||
// `engine` is a package-level VARIABLE holding a func value, not a factory
|
||||
// FUNCTION — so its return type can't be recovered and the chain falls back
|
||||
// to bare-name resolution of the method (restoring the pre-re-encoding edge).
|
||||
// Regression for the runaway this fallback originally caused: it resolved
|
||||
// with a mutated `original.referenceName` (the bare `ServeHTTP`, not the
|
||||
// stored `engine().ServeHTTP`), so the batched resolver's keyed delete
|
||||
// no-oped, the offset-0 batch never drained, and edges inserted forever
|
||||
// (5M edges / 1.4 GB on a 99-file repo). The fallback now ties the match to
|
||||
// the original ref, and a non-progress guard backstops the loop.
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, 'main.go'),
|
||||
`package main
|
||||
type Server struct{}
|
||||
func (s *Server) ServeHTTP() {}
|
||||
var engine = func() *Server { return &Server{} }
|
||||
func caller() { engine().ServeHTTP() }
|
||||
`
|
||||
);
|
||||
cg = await CodeGraph.init(tempDir, { index: true });
|
||||
// Recall: the variable-inner chain still finds the method by bare name.
|
||||
expect(callerNamesOf('Server::ServeHTTP')).toEqual(['caller']);
|
||||
// No runaway: a single call site yields a single edge, not millions.
|
||||
const target = cg
|
||||
.getNodesByKind('method')
|
||||
.find((n) => n.qualifiedName === 'Server::ServeHTTP')!;
|
||||
const rawCalls = cg
|
||||
.getIncomingEdges(target.id)
|
||||
.filter((e) => e.kind === 'calls');
|
||||
expect(rawCalls.length).toBeLessThan(5);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user