* 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
@@ -21,4 +21,4 @@
|
||||
* turns the re-index hint into noise — keep it honest (see CLAUDE.md, "Honesty
|
||||
* in the product is load-bearing").
|
||||
*/
|
||||
export const EXTRACTION_VERSION = 11;
|
||||
export const EXTRACTION_VERSION = 12;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -2529,18 +2529,19 @@ export class TreeSitterExtractor {
|
||||
this.language === 'c' ||
|
||||
this.language === 'kotlin' ||
|
||||
this.language === 'swift' ||
|
||||
this.language === 'rust') &&
|
||||
this.language === 'rust' ||
|
||||
this.language === 'go') &&
|
||||
receiver &&
|
||||
receiver.type === 'call_expression'
|
||||
) {
|
||||
// Receiver that is itself a call — `Foo::instance().bar()`,
|
||||
// `openSession()->run()`, `mgr.view().render()` (C/C++),
|
||||
// `Foo.getInstance().bar()` (Kotlin) / `Foo.make().draw()` (Swift), or
|
||||
// `Foo::new().bar()` (Rust). Keep the inner call so resolution can
|
||||
// infer bar()'s class from what the inner call RETURNS (#645/#608).
|
||||
// Encode as `<innerCallee>().<method>`; the `().` marker never appears
|
||||
// in an ordinary ref, so the resolver can detect and split it. Other
|
||||
// languages keep the bare-name behavior (dropping the receiver) below.
|
||||
// `Foo.getInstance().bar()` (Kotlin) / `Foo.make().draw()` (Swift),
|
||||
// `Foo::new().bar()` (Rust), or `New().Method()` (Go). Keep the inner
|
||||
// call so resolution can infer bar()'s class from what the inner call
|
||||
// RETURNS (#645/#608). Encode as `<innerCallee>().<method>`; the `().`
|
||||
// marker never appears in an ordinary ref, so the resolver can detect
|
||||
// and split it. Other languages keep the bare-name behavior below.
|
||||
let innerCallee: string;
|
||||
let reencode: boolean;
|
||||
if (this.language === 'kotlin' || this.language === 'swift') {
|
||||
@@ -2564,11 +2565,14 @@ export class TreeSitterExtractor {
|
||||
: '';
|
||||
// Rust: only re-encode an associated-function chain
|
||||
// (`Foo::new().bar()`), whose inner callee is a path/`scoped_identifier`.
|
||||
// An instance chain (`x.foo().bar()`, inner callee a field_expression)
|
||||
// keeps bare-name — the `::` resolver can't recover a variable's type,
|
||||
// so re-encoding would only drop the edge. C/C++ re-encode any inner.
|
||||
reencode =
|
||||
this.language === 'rust' ? innerFn?.type === 'scoped_identifier' : !!innerCallee;
|
||||
// Go: only a bare package-level factory chain (`New().Method()`),
|
||||
// whose inner callee is an `identifier`. An instance chain
|
||||
// (`x.foo().bar()` Rust, `obj.Method().Other()` Go) keeps bare-name —
|
||||
// the resolver can't recover a variable's type, so re-encoding would
|
||||
// only drop the edge. C/C++ re-encode any inner.
|
||||
if (this.language === 'rust') reencode = innerFn?.type === 'scoped_identifier';
|
||||
else if (this.language === 'go') reencode = innerFn?.type === 'identifier';
|
||||
else reencode = !!innerCallee;
|
||||
}
|
||||
calleeName = reencode ? `${innerCallee}().${methodName}` : methodName;
|
||||
} else {
|
||||
|
||||
+14
-1
@@ -37,7 +37,7 @@ const SUPERTYPE_BEARING_KINDS = new Set<Node['kind']>([
|
||||
* second pass. Dotted-receiver languages resolve via matchDottedCallChain; the
|
||||
* `::`-receiver ones (Rust) via matchScopedCallChain.
|
||||
*/
|
||||
const CHAIN_LANGUAGES = new Set(['java', 'kotlin', 'csharp', 'swift', 'rust']);
|
||||
const CHAIN_LANGUAGES = new Set(['java', 'kotlin', 'csharp', 'swift', 'rust', 'go']);
|
||||
const SCOPED_CHAIN_LANGUAGES = new Set(['rust']);
|
||||
|
||||
/** The extractor's chained-receiver encoding: `<inner>().<method>`. */
|
||||
@@ -884,6 +884,7 @@ export class ReferenceResolver {
|
||||
|
||||
// Process in batches. We always read from offset 0 because resolved refs
|
||||
// are deleted after each batch, shifting the remaining rows forward.
|
||||
let prevRemaining = Number.POSITIVE_INFINITY;
|
||||
while (true) {
|
||||
const batch = this.queries.getUnresolvedReferencesBatch(0, batchSize);
|
||||
if (batch.length === 0) break;
|
||||
@@ -937,6 +938,18 @@ export class ReferenceResolver {
|
||||
if (result.resolved.length === 0 && result.unresolved.length === batch.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Non-progress guard (defense-in-depth). Because we re-read from offset 0
|
||||
// each pass, the unresolved_refs table MUST shrink every iteration — both
|
||||
// resolved and unresolved refs are deleted above. If it didn't shrink, a
|
||||
// resolver returned a match whose `original.referenceName` differs from the
|
||||
// stored row, so the keyed delete no-ops, and we'd re-read + re-resolve +
|
||||
// re-insert the same rows forever (the runaway that grew a 99-file repo to
|
||||
// 5M edges / 1.4 GB before the Go-fallback fix). Stop rather than grow the
|
||||
// graph without bound.
|
||||
const remaining = this.queries.getUnresolvedReferencesCount();
|
||||
if (remaining >= prevRemaining) break;
|
||||
prevRemaining = remaining;
|
||||
}
|
||||
|
||||
// Dynamic-edge synthesis: now that all base `calls` edges are persisted,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user