fix(go): resolve chained factory-function calls New().Method() (#750) (#760)

* 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:
Colby Mchenry
2026-06-09 11:31:24 -04:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 5805f01957
commit ccced9e358
7 changed files with 222 additions and 26 deletions
+38
View File
@@ -1,6 +1,43 @@
import type { Node as SyntaxNode } from 'web-tree-sitter';
import { getNodeText, getChildByField } from '../tree-sitter-helpers';
import type { LanguageExtractor } from '../tree-sitter-types';
/**
* A Go function's declared return type, normalized to the bare type a chained
* `New().Method()` could be called on (the #645/#608 mechanism). Reads the
* `result` field: a pointer `*Foo` is unwrapped to `Foo`, a multi-return
* `(*Foo, error)` takes the first result (the idiomatic value-or-error shape),
* a qualified `pkg.Foo` reduces to its last segment, and generics to the base.
* Built-ins / unnamed results simply fail the later existence check.
*/
function extractGoReturnType(node: SyntaxNode, source: string): string | undefined {
let result = getChildByField(node, 'result');
if (!result) return undefined;
// Multi-return `(T, error)` → the first result's type.
if (result.type === 'parameter_list') {
const first = result.namedChildren.find((c: SyntaxNode) => c.type === 'parameter_declaration');
if (!first) return undefined;
result = getChildByField(first, 'type') ?? first;
}
// Unwrap a pointer `*Foo` → `Foo`.
if (result?.type === 'pointer_type') {
result =
result.namedChildren.find(
(c: SyntaxNode) =>
c.type === 'type_identifier' || c.type === 'qualified_type' || c.type === 'generic_type',
) ?? result;
}
if (!result) return undefined;
const text = getNodeText(result, source)
.trim()
.replace(/^\*/, '')
.replace(/<[^>]*>/g, '')
.replace(/\[[^\]]*\]/g, ''); // strip generic args `Foo[T]`
const last = text.split('.').pop()?.trim(); // qualified `pkg.Foo` → `Foo`
if (!last || !/^[A-Za-z_]\w*$/.test(last)) return undefined;
return last;
}
export const goExtractor: LanguageExtractor = {
functionTypes: ['function_declaration'],
classTypes: [], // Go doesn't have classes
@@ -17,6 +54,7 @@ export const goExtractor: LanguageExtractor = {
bodyField: 'body',
paramsField: 'parameters',
returnField: 'result',
getReturnType: extractGoReturnType,
getSignature: (node, source) => {
const params = getChildByField(node, 'parameters');
const result = getChildByField(node, 'result');