Skip to content

Commit 3e04650

Browse files
colbymchenryclaude
andauthored
fix(kotlin): resolve chained companion-factory calls Foo.getInstance().bar() (colbymchenry#750) (colbymchenry#752)
A Kotlin method called through a companion-object factory, fluent chain, or constructor — `Foo.getInstance().bar()`, `Config.create(opts).build()`, `STMTransaction(f).commit()` — dropped the receiver to a BARE method name, which then name-matched a same-named method on an unrelated class (a wrong edge) or failed to resolve. Ports the colbymchenry#645/colbymchenry#608 mechanism to Kotlin: - Part 1: capture Kotlin return types in the extractor. tree-sitter-kotlin exposes no field names, so the return type is read positionally (the type node after function_value_parameters); inferred/Unit/Nothing returns yield none. - Part 2: encode a CLASS/companion-factory call-receiver chain as `inner().method`. Gated to a capitalized receiver (`Foo.getInstance()` / `Foo(args)`) so instance chains (`list.filter{}.map{}`) keep their bare-name behavior — re-encoding those would only drop the edge, regressing recall in fluent codebases. - Part 3: generalize matchJavaCallChain -> matchDottedCallChain (shared by the JVM dot-notation languages); resolve the method on the factory's return type, or on the constructed class for a Kotlin `Foo(args).method()` receiver. Validated via resolveMethodOnType, so a wrong inference yields NO edge. Validated: synthetic decoy + args + absent-method safety tests; full suite green; real-repo A/B on arrow-kt/arrow (734 .kt) — node count identical (no explosion), +49 validated-correct chained edges, and the removed edges are wrong bare-name guesses the fix correctly stops emitting (419/438 from test/doc files; the 18 from product code are stdlib `.apply{}`, self-loops, and bare-name mismatches) — a net precision improvement, ~0 correct product edges lost. Java path unchanged (constructor branch is Kotlin-gated). EXTRACTION_VERSION 6 -> 7. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 7f6bdf7 commit 3e04650

6 files changed

Lines changed: 187 additions & 29 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
2929

3030
### Fixes
3131

32+
- Kotlin method calls made through a companion-object factory or fluent chain now resolve to the correct class. A call like `Foo.getInstance().bar()` or `Config.create(opts).build()` used to drop the receiver entirely, so the chained method silently attached to a same-named method on an unrelated class — or didn't resolve at all — corrupting callers, impact, and trace. CodeGraph now captures Kotlin return types and infers the chained receiver's type from what the inner call returns, creating the edge only when that class genuinely has the method (so a wrong inference produces no edge instead of a misleading one). Existing Kotlin indexes should be re-indexed (`codegraph index -f`) to benefit. (#750) (Kotlin)
3233
- Java method calls made through a static factory or fluent chain now resolve to the correct class. A call like `Foo.getInstance().bar()` or `Config.create(opts).build()` used to lose the receiver's type, so when two classes had a same-named method the call silently attached to whichever was indexed first — or didn't resolve at all — corrupting callers, impact, and trace. CodeGraph now captures Java return types and infers the chained receiver's type from what the inner call returns, creating the edge only when that class genuinely has the method (so a wrong inference produces no edge instead of a misleading one). Covers factories and fluent builders that take arguments (`hashKeys().arrayListValues()`), including builders that return a nested type. Existing Java indexes should be re-indexed (`codegraph index -f`) to benefit. (#750) (Java)
3334
- PHP: a method called through a chained static factory — `Cls::for($x)->method(...)`, the canonical Laravel per-credential / per-tenant client idiom — now records a caller edge. Previously the receiver type (what `for()` returns) was never recovered, so `codegraph_callers` returned nothing for the method and the call was invisible to `codegraph_impact`. CodeGraph now captures PHP return types — `: self` / `: static` resolve to the declaring class, `: SomeClass` to that class — and resolves the chained method on the factory's result, creating the edge only when that class actually has the method (so a wrong inference produces no edge). Existing PHP indexes should be re-indexed (`codegraph index -f`) to benefit. Thanks @cvanderlinden. (#608) (PHP)
3435
- Search relevance: including the project name in a query (a user naturally writes `MyApp backend routes`) no longer buries the part of the codebase the query is actually about. The project name lexically matches whatever stack embeds it — a `MyAppFrontend/` directory, a `MyAppApp` class — and it was over-weighted two ways: a single PascalCase word was scored once per sub-token (`my` / `app` / `myapp`), so one concept boosted that path several times over; and the name carried full path / disambiguation weight even though it names the whole repo, not any symbol. Now path relevance counts each query word once, and a word matching the project name (derived from `go.mod`, `package.json`, or the repo directory) is dropped from path scoring and from `codegraph_explore`'s type-disambiguation bias — unless it's the only term, so a bare project-name search still works. In a mixed-stack repo, a backend question now surfaces the backend even with the project name in the query. Thanks @MiNuo1. (#720)

__tests__/resolution.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2256,6 +2256,79 @@ class Other { void onlyOther() {} }
22562256
class Caller {
22572257
void run() { Foo.getInstance().onlyOther(); }
22582258
}
2259+
`
2260+
);
2261+
cg = await CodeGraph.init(tempDir, { index: true });
2262+
// Foo has no onlyOther() — must not mis-attach to the same-named Other::onlyOther.
2263+
expect(callerNamesOf('Other::onlyOther')).toEqual([]);
2264+
});
2265+
});
2266+
2267+
describe('Kotlin chained companion-factory call resolution (#645/#608 mechanism)', () => {
2268+
function callerNamesOf(qualifiedName: string): string[] {
2269+
const target = cg.getNodesByKind('method').find((n) => n.qualifiedName === qualifiedName);
2270+
if (!target) return [];
2271+
const names = cg
2272+
.getIncomingEdges(target.id)
2273+
.filter((e) => e.kind === 'calls')
2274+
.map((e) => cg.getNode(e.source)?.name)
2275+
.filter((n): n is string => !!n);
2276+
return [...new Set(names)].sort();
2277+
}
2278+
2279+
it('resolves Foo.getInstance().bar() via the companion return type, never a same-named decoy', async () => {
2280+
// Aaa sorts first and has a same-named bar() — without the chain fix Kotlin
2281+
// dropped the receiver to a bare `bar` and attached to Aaa (a wrong edge).
2282+
fs.writeFileSync(
2283+
path.join(tempDir, 'Main.kt'),
2284+
`class Aaa { fun bar() {} }
2285+
class Foo {
2286+
companion object {
2287+
fun getInstance(): Foo = Foo()
2288+
}
2289+
fun bar() {}
2290+
}
2291+
class Caller {
2292+
fun run() { Foo.getInstance().bar() }
2293+
}
2294+
`
2295+
);
2296+
cg = await CodeGraph.init(tempDir, { index: true });
2297+
expect(callerNamesOf('Foo::bar')).toEqual(['run']);
2298+
expect(callerNamesOf('Aaa::bar')).toEqual([]);
2299+
});
2300+
2301+
it('resolves a companion factory chain that passes arguments — Foo.create(cfg).build()', async () => {
2302+
fs.writeFileSync(
2303+
path.join(tempDir, 'Main.kt'),
2304+
`class Config
2305+
class Foo {
2306+
companion object {
2307+
fun create(c: Config): Foo = Foo()
2308+
}
2309+
fun build() {}
2310+
}
2311+
class Caller {
2312+
fun run() { Foo.create(Config()).build() }
2313+
}
2314+
`
2315+
);
2316+
cg = await CodeGraph.init(tempDir, { index: true });
2317+
expect(callerNamesOf('Foo::build')).toEqual(['run']);
2318+
});
2319+
2320+
it('creates NO edge when the companion return type lacks the method (silent miss, not a wrong edge)', async () => {
2321+
fs.writeFileSync(
2322+
path.join(tempDir, 'Main.kt'),
2323+
`class Foo {
2324+
companion object {
2325+
fun getInstance(): Foo = Foo()
2326+
}
2327+
}
2328+
class Other { fun onlyOther() {} }
2329+
class Caller {
2330+
fun run() { Foo.getInstance().onlyOther() }
2331+
}
22592332
`
22602333
);
22612334
cg = await CodeGraph.init(tempDir, { index: true });

src/extraction/extraction-version.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,4 @@
2121
* turns the re-index hint into noise — keep it honest (see CLAUDE.md, "Honesty
2222
* in the product is load-bearing").
2323
*/
24-
export const EXTRACTION_VERSION = 6;
24+
export const EXTRACTION_VERSION = 7;

src/extraction/languages/kotlin.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,46 @@ import type { Node as SyntaxNode } from 'web-tree-sitter';
22
import { getNodeText, getChildByField } from '../tree-sitter-helpers';
33
import type { LanguageExtractor } from '../tree-sitter-types';
44

5+
/** Kotlin return types that can't be a chained-call receiver (no class to chain on). */
6+
const KOTLIN_NON_CLASS_RETURN = new Set(['Unit', 'Nothing']);
7+
8+
/**
9+
* A Kotlin function's declared return type, normalized to the bare class name a
10+
* chained `Foo.getInstance().bar()` could be called on (the #645/#608 mechanism).
11+
* tree-sitter-kotlin exposes no field names, so the return type is found
12+
* positionally: the first `user_type` / `nullable_type` that FOLLOWS
13+
* `function_value_parameters` (an extension receiver's type sits before the
14+
* params, so it's never mistaken for the return). An inferred return (expression
15+
* body with no `: Type`), a lambda return type, or `Unit` / `Nothing` → undefined.
16+
*/
17+
function extractKotlinReturnType(node: SyntaxNode, source: string): string | undefined {
18+
let seenParams = false;
19+
for (let i = 0; i < node.namedChildCount; i++) {
20+
const child = node.namedChild(i);
21+
if (!child) continue;
22+
if (child.type === 'function_value_parameters') {
23+
seenParams = true;
24+
continue;
25+
}
26+
if (!seenParams) continue;
27+
// The return type is the type node right after the params. If we reach the
28+
// body or a `where`-clause first, there's no declared return type.
29+
if (child.type === 'function_body' || child.type === 'type_constraints') return undefined;
30+
if (child.type === 'user_type' || child.type === 'nullable_type') {
31+
const ut =
32+
child.type === 'nullable_type'
33+
? (child.namedChildren.find((c: SyntaxNode) => c.type === 'user_type') ?? child)
34+
: child;
35+
const typeId = ut.namedChildren.find((c: SyntaxNode) => c.type === 'type_identifier');
36+
const name = getNodeText(typeId ?? ut, source).trim();
37+
if (!name || !/^[A-Za-z_]\w*$/.test(name)) return undefined;
38+
if (KOTLIN_NON_CLASS_RETURN.has(name)) return undefined;
39+
return name;
40+
}
41+
}
42+
return undefined;
43+
}
44+
545
/** Check if a node matches the `fun interface` misparse pattern */
646
function isFunInterfaceNode(node: SyntaxNode): boolean {
747
let hasFun = false;
@@ -130,6 +170,7 @@ export const kotlinExtractor: LanguageExtractor = {
130170
},
131171
paramsField: 'function_value_parameters',
132172
returnField: 'type',
173+
getReturnType: extractKotlinReturnType,
133174
resolveBody: (node, _bodyField) => {
134175
// Kotlin's tree-sitter grammar doesn't use field names, so getChildByField fails.
135176
// Find body by type: function_body for functions/methods, class_body for classes,

src/extraction/tree-sitter.ts

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2525,22 +2525,41 @@ export class TreeSitterExtractor {
25252525
calleeName = methodName;
25262526
}
25272527
} else if (
2528-
(this.language === 'cpp' || this.language === 'c') &&
2528+
(this.language === 'cpp' || this.language === 'c' || this.language === 'kotlin') &&
25292529
receiver &&
25302530
receiver.type === 'call_expression'
25312531
) {
2532-
// C/C++ receiver that is itself a call — `Foo::instance().bar()`,
2533-
// `openSession()->run()`, `mgr.view().render()`. Keep the inner
2534-
// call so resolution can infer bar()'s class from what the inner
2535-
// call RETURNS (#645). Encode as `<innerCallee>().<method>`; the
2536-
// `().` marker never appears in an ordinary ref, so the C++
2537-
// resolver can detect and split it. Other languages keep the
2538-
// bare-name behavior (dropping the receiver) below.
2539-
const innerFn = getChildByField(receiver, 'function');
2540-
const innerCallee = innerFn
2541-
? getNodeText(innerFn, this.source).replace(/->/g, '.').replace(/\s+/g, '')
2542-
: '';
2543-
calleeName = innerCallee ? `${innerCallee}().${methodName}` : methodName;
2532+
// Receiver that is itself a call — `Foo::instance().bar()`,
2533+
// `openSession()->run()`, `mgr.view().render()` (C/C++), or
2534+
// `Foo.getInstance().bar()` (Kotlin). Keep the inner call so
2535+
// resolution can infer bar()'s class from what the inner call
2536+
// RETURNS (#645/#608). Encode as `<innerCallee>().<method>`; the
2537+
// `().` marker never appears in an ordinary ref, so the resolver
2538+
// can detect and split it. Other languages keep the bare-name
2539+
// behavior (dropping the receiver) below.
2540+
let innerCallee: string;
2541+
let reencode: boolean;
2542+
if (this.language === 'kotlin') {
2543+
// tree-sitter-kotlin has no field names — the inner callee is the
2544+
// call_expression's first named child (a navigation_expression
2545+
// `Foo.getInstance`, or a bare identifier for a free call).
2546+
const innerNav = receiver.namedChild(0);
2547+
innerCallee = innerNav ? getNodeText(innerNav, this.source).replace(/\s+/g, '') : '';
2548+
// Only re-encode a CLASS / companion-factory chain, whose receiver
2549+
// chain starts with a capitalized type (`Foo.getInstance().bar()`).
2550+
// An instance chain (`list.filter{}.map{}`) has a lowercase receiver
2551+
// whose type we can't recover here — re-encoding it would only drop
2552+
// the edge (no chain resolution, no bare-name fallback), regressing
2553+
// recall in fluent codebases. Leave those to the bare-name path.
2554+
reencode = /^[A-Z]/.test(innerCallee);
2555+
} else {
2556+
const innerFn = getChildByField(receiver, 'function');
2557+
innerCallee = innerFn
2558+
? getNodeText(innerFn, this.source).replace(/->/g, '.').replace(/\s+/g, '')
2559+
: '';
2560+
reencode = !!innerCallee;
2561+
}
2562+
calleeName = reencode ? `${innerCallee}().${methodName}` : methodName;
25442563
} else {
25452564
calleeName = methodName;
25462565
}

src/resolution/name-matcher.ts

Lines changed: 39 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -577,36 +577,59 @@ export function matchPhpCallChain(
577577
}
578578

579579
/**
580-
* Resolve a Java chained call whose receiver is a static factory / fluent call —
580+
* Resolve a dotted chained call whose receiver is a static factory / fluent call —
581581
* `Foo.getInstance().bar()`, encoded by the extractor as `Foo.getInstance().bar`
582582
* (#645/#608 mechanism). The receiver's type is what `Foo.getInstance` returns
583583
* (its declared return type); the outer method is then resolved and VALIDATED on
584584
* it (resolveMethodOnType requires `Type::method` to exist), so a wrong inference
585585
* yields no edge rather than a wrong one (e.g. a same-named `bar()` on an
586-
* unrelated class is never matched).
586+
* unrelated class is never matched). Shared by the JVM dot-notation languages
587+
* (Java, Kotlin) — same receiver shape, same `Class::method` qualified names.
587588
*/
588-
export function matchJavaCallChain(
589+
export function matchDottedCallChain(
589590
ref: UnresolvedRef,
590591
context: ResolutionContext,
591592
): ResolvedRef | null {
592593
const m = ref.referenceName.match(/^(.+)\(\)\.(\w+)$/);
593594
if (!m || !m[1] || !m[2]) return null;
594595
const inner = m[1]; // `Foo.getInstance`
595596
const method = m[2]; // `bar`
596-
// Require an explicit receiver (`Receiver.factory`) — a bare `factory().bar`
597-
// chain (a method on `this`) isn't handled here.
598597
const lastDot = inner.lastIndexOf('.');
599-
if (lastDot <= 0) return null;
598+
599+
// Constructor receiver `Foo(args).method()` (encoded `Foo().method`): a bare,
600+
// capitalized inner is a class construction, so the receiver's type is the
601+
// class itself — resolve the method on it. Kotlin only: there an unprefixed
602+
// capitalized call constructs the class, whereas in Java a bare `Foo()` is a
603+
// method call (constructors need `new`), so we must not assume construction.
604+
// A lowercase bare inner is a top-level `factory().method()` whose type we
605+
// can't recover — bail.
606+
if (lastDot <= 0) {
607+
if (ref.language !== 'kotlin' || !/^[A-Z]/.test(inner)) return null;
608+
return resolveMethodOnType(inner, method, ref, context, 0.85, 'instance-method', importedFqnOf(inner, ref, context));
609+
}
610+
611+
// Factory/fluent receiver `Receiver.factory(args).method()`: the receiver's
612+
// type is what `Receiver.factory` returns (its declared return type).
600613
const factoryClass = inner.slice(0, lastDot).split('.').pop(); // simple class name
601614
const factoryMethod = inner.slice(lastDot + 1);
602615
if (!factoryClass || !factoryMethod) return null;
603616
const ret = lookupCalleeReturnType(`${factoryClass}::${factoryMethod}`, ref, context);
604617
if (!ret) return null;
605-
// When several classes share the returned simple name, the caller file's
606-
// import of that type is the only signal that names WHICH one (#314).
618+
return resolveMethodOnType(ret, method, ref, context, 0.85, 'instance-method', importedFqnOf(ret, ref, context));
619+
}
620+
621+
/**
622+
* When several classes share a simple type name, the caller file's import of
623+
* that type is the only signal that names WHICH one (#314). Returns the imported
624+
* FQN for `typeName` in the ref's file, or undefined.
625+
*/
626+
function importedFqnOf(
627+
typeName: string,
628+
ref: UnresolvedRef,
629+
context: ResolutionContext,
630+
): string | undefined {
607631
const imports = context.getImportMappings(ref.filePath, ref.language);
608-
const importedFqn = imports.find((i) => i.localName === ret)?.source;
609-
return resolveMethodOnType(ret, method, ref, context, 0.85, 'instance-method', importedFqn);
632+
return imports.find((i) => i.localName === typeName)?.source;
610633
}
611634

612635
/**
@@ -1039,11 +1062,12 @@ export function matchReference(
10391062
if (result) return result;
10401063
}
10411064

1042-
// 1d. Java chained static-factory / fluent call — `Foo.getInstance().bar()`
1043-
// encoded as `Foo.getInstance().bar` (#645/#608 mechanism). Resolve bar's class
1044-
// from getInstance's declared return type, then validate the method on it.
1045-
if (ref.language === 'java') {
1046-
result = matchJavaCallChain(ref, context);
1065+
// 1d. JVM (Java / Kotlin) chained static-factory / fluent call —
1066+
// `Foo.getInstance().bar()` encoded as `Foo.getInstance().bar` (#645/#608
1067+
// mechanism). Resolve bar's class from getInstance's declared return type, then
1068+
// validate the method on it.
1069+
if (ref.language === 'java' || ref.language === 'kotlin') {
1070+
result = matchDottedCallChain(ref, context);
10471071
if (result) return result;
10481072
}
10491073

0 commit comments

Comments
 (0)