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
+41 -12
View File
@@ -624,14 +624,42 @@ export function matchDottedCallChain(
const method = m[2]; // `bar`
const lastDot = inner.lastIndexOf('.');
// Constructor receiver `Foo(args).method()` (encoded `Foo().method`): a bare,
// capitalized inner is a class construction, so the receiver's type is the
// class itself — resolve the method on it. Only in languages where an
// unprefixed capitalized call constructs the class (Kotlin, Swift); in Java/C#
// a bare `Foo()` is a method call (constructors need `new`), so we must not
// assume construction. A lowercase bare inner is a top-level `factory().method()`
// whose type we can't recover — bail.
if (lastDot <= 0) {
// Go: bare package-level factory FUNCTION `New().method()` — the receiver's
// type is what `New` returns; resolve the method on that.
if (ref.language === 'go') {
const ret = lookupCalleeReturnType(inner, ref, context);
if (ret) {
return resolveMethodOnType(ret, method, ref, context, 0.85, 'instance-method', importedFqnOf(ret, ref, context));
}
// `inner` isn't a function with a captured return type — typically a
// package-level VARIABLE holding a function value (e.g. gin's `engine()`),
// whose type we can't recover. Fall back to bare-name resolution of the
// method so we don't DROP an edge the un-re-encoded bare path would have
// found. (When `inner` IS a real factory function but the method doesn't
// exist on its return type, `ret` is truthy and we returned no edge above —
// the absent-method safety guarantee is preserved.)
//
// CRITICAL: resolve the TARGET via a synthetic bare-name ref, but return the
// match tied to the ORIGINAL `ref` (referenceName `inner().method`). The
// batched resolver (resolveAndPersistBatched) reads unresolved rows from
// offset 0 every pass and relies on deleteSpecificResolvedReferences —
// keyed on referenceName — to clear each resolved row so the batch empties.
// If we propagated the synthetic ref's bare `method` as `.original`, the
// delete would never match the stored `inner().method` row, the batch would
// never drain, and the loop would re-resolve + re-insert forever (a runaway
// that grew gin's graph to 5M edges / 1.4 GB before this fix).
const bareRef = { ...ref, referenceName: method };
const bareMatch = matchByExactName(bareRef, context) ?? matchFuzzy(bareRef, context);
return bareMatch ? { ...bareMatch, original: ref } : null;
}
// Constructor receiver `Foo(args).method()` (encoded `Foo().method`): a bare,
// capitalized inner is a class construction, so the receiver's type is the
// class itself — resolve the method on it. Only in languages where an
// unprefixed capitalized call constructs the class (Kotlin, Swift); in Java/C#
// a bare `Foo()` is a method call (constructors need `new`), so we must not
// assume construction. A lowercase bare inner is a top-level `factory().method()`
// whose type we can't recover — bail.
if (!CONSTRUCTS_VIA_BARE_CALL.has(ref.language) || !/^[A-Z]/.test(inner)) return null;
return resolveMethodOnType(inner, method, ref, context, 0.85, 'instance-method', importedFqnOf(inner, ref, context));
}
@@ -1091,15 +1119,16 @@ export function matchReference(
if (result) return result;
}
// 1d. Dotted chained static-factory / fluent call (Java / Kotlin / C# / Swift) —
// `Foo.getInstance().bar()` encoded as `Foo.getInstance().bar` (#645/#608
// mechanism). Resolve bar's class from getInstance's declared return type, then
// validate the method on it.
// 1d. Dotted chained static-factory / fluent call (Java / Kotlin / C# / Swift /
// Go) — `Foo.getInstance().bar()` encoded as `Foo.getInstance().bar`, or Go's
// bare-factory `New().Method()` as `New().Method` (#645/#608 mechanism). Resolve
// the method's class from the inner call's declared return type, then validate it.
if (
ref.language === 'java' ||
ref.language === 'kotlin' ||
ref.language === 'csharp' ||
ref.language === 'swift'
ref.language === 'swift' ||
ref.language === 'go'
) {
result = matchDottedCallChain(ref, context);
if (result) return result;