From da70ad6902dd5700f6959bbbea0e3a6e0a4180da Mon Sep 17 00:00:00 2001 From: Aaron Queen Date: Mon, 12 Oct 2026 07:36:46 -0600 Subject: [PATCH] fix(kotlin): a call on a typed receiver links to an extension function declared in a package An extension function is stored as `Foo::shout` whatever its package, while the class it extends is `pkg::Foo`. The typed-receiver lookup found a method by the class's qualified name, so the extension linked only in files with no `package` line (the common project has one, so it got no edge). When the receiver's class has no such member, a Kotlin call now falls back to the one visible extension of that name whose receiver accepts the type, as an implicit receiver already does. On JetBrains Exposed (2155404) 1,020 call links are gained and none is lost; a sample of 16 and the ones whose owner is a standard type (Sequence, Set, Triple, Iterable, String) all check out against the source. The golden dumps do not move. kotlin-infix-calls pinned the gap ('an extension on a project class is not linked through a typed receiver'); it now expects `Int::and`. --- CHANGELOG.md | 2 + README.md | 1 + ...tlin-extension-call-typed-receiver.test.ts | 51 +++++++++++++++++++ __tests__/kotlin-infix-calls.test.ts | 6 +-- .../src/resolve/kotlin_receiver.rs | 7 +++ codegraph-kernel/src/resolve/method_call.rs | 10 +++- 6 files changed, 73 insertions(+), 4 deletions(-) create mode 100644 __tests__/kotlin-extension-call-typed-receiver.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bbf2b498..738ca4cd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -574,6 +574,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `codegraph sync` and the file watcher now update the files that import through a module that re-exports what it imports, like a package's `__init__.py` or an `index.ts` barrel, when that module is deleted or added, and in Python also the files that reach it through another package re-exporting it. Before, deleting `shop/__init__.py` left every `from shop import Order` linked to the class it re-exported, and deleting `components/index.ts` left every `import { Button } from './components'` linked, until each importing file changed or the project was re-indexed. When such a module came back, the imports stayed linked the way they were while it was missing, and in a large Python project an import of a module whose name is as common as `models` or `test`, like `from django.test import TestCase`, stayed unlinked. Python, JavaScript, TypeScript, Vue, Svelte and Astro. +- In Kotlin, a call on a typed receiver now links to an extension function declared in a package, like `value.shout()` or the infix `a join b` with `value: Foo` and `fun Foo.shout()` under `package demo`, including one declared in another file of the package. Before, it linked only when the file had no `package` line, so the usual Kotlin project got no edge. On JetBrains Exposed, 1,020 such calls now link (`Table.selectAll()` to `FieldSet.selectAll`, `transaction.throwUnsupportedException(…)`), and none that linked before is lost. A member of the receiver's class still wins over an extension of the same name. + #### MCP / indexing - Full indexes of large projects no longer sometimes run two to three times slower than usual. A timing clash between two background database maintenance steps could switch reference resolution from several threads to one partway through. diff --git a/README.md b/README.md index 6da976ad3..224304b82 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,7 @@ Call resolution the fork adds: - Kotlin infix calls keep their call edge when a comment sits inside the expression, and a parenthesized receiver (`(b) put 1`) is typed like a bare one. - Kotlin receiver inference follows bounded chains of declared returns and verified receiver-preserving methods. Properties initialized by typed factory calls keep compatible imported extensions. Explicit casts, single-type `when` branches, filtered collection elements and bound generic factory arguments also supply receiver types. - Kotlin chains on instance and extension receivers use the declared return type, including nested and multiline calls; upstream covers class and companion-factory chains. An imported return-type guess for an unknown receiver keeps confidence at most 0.7, and so does a Rust chain match without a proved receiver type. +- Kotlin calls on a typed receiver link to extension functions declared in a package, infix ones included (`value.shout()`, `a join b`); on JetBrains Exposed that adds 1,020 call links and removes none. - Kotlin multi-dollar strings keep their interpolation threshold. - C++ typedef and using aliases follow their declaration scope, including nested owners and inherited aliases. Pointer, array and function members are not supertypes. Visible class-scoped aliases and declared complex receivers keep their method owners; unsubstituted template parameters and ambiguous owners stay unresolved. - C++ `->` calls on a `std::unique_ptr`, `std::shared_ptr` or `std::optional` reach the type it holds, looked up through the caller's enclosing classes and their bases, its namespaces, `using` directives and aliases. A held type the project does not declare leaves the call unresolved, as a plain pointer to one does; upstream guesses a callee from the receiver's name there. A partial class template specialization is indexed as an anonymous class, so a call to a member only it declares stays unresolved. diff --git a/__tests__/kotlin-extension-call-typed-receiver.test.ts b/__tests__/kotlin-extension-call-typed-receiver.test.ts new file mode 100644 index 000000000..2632e6ddb --- /dev/null +++ b/__tests__/kotlin-extension-call-typed-receiver.test.ts @@ -0,0 +1,51 @@ +import { expect, it } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { CodeGraph } from '../src'; + +// An extension function's qualified name is `Foo::shout` whatever its package, while the class +// it extends is `pkg::Foo`: the call on a typed receiver has to find it by the receiver's type. +it('links a call on a typed receiver to an extension function declared in a package', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-kotlin-ext-pkg-')); + let cg: CodeGraph | undefined; + try { + fs.writeFileSync(path.join(dir, 'Foo.kt'), `package demo + +class Foo +class Bar +class Baz { fun shout(): String = "member" } +`); + fs.writeFileSync(path.join(dir, 'Ext.kt'), `package demo + +fun Foo.shout(): String = "x" +infix fun Foo.join(other: Foo): Foo = this +fun Baz.shout(loud: Boolean): String = "extension" +fun Bar.whisper(): String = "y" +`); + fs.writeFileSync(path.join(dir, 'Use.kt'), `package demo + +fun byParameter(value: Foo): String = value.shout() +fun byConstructor(): String { + val f = Foo() + return f.shout() +} +fun byInfix(a: Foo, b: Foo): Foo = a join b +fun memberWins(value: Baz): String = value.shout() +fun otherType(value: Bar): String = value.shout() +fun unrelatedExtension(value: Foo): String = value.whisper() +`); + cg = await CodeGraph.init(dir, { index: true }); + const targets = (name: string) => cg!.getOutgoingEdges(cg!.getNodesInFile('Use.kt').find((n) => n.name === name)!.id) + .filter((e) => e.kind === 'calls').map((e) => cg!.getNode(e.target)!.qualifiedName + '@' + cg!.getNode(e.target)!.filePath); + expect(targets('byParameter')).toEqual(['Foo::shout@Ext.kt']); + expect(targets('byConstructor')).toEqual(['Foo::shout@Ext.kt']); + expect(targets('byInfix')).toEqual(['Foo::join@Ext.kt']); + expect(targets('memberWins')).toEqual(['demo::Baz::shout@Foo.kt']); + expect(targets('otherType')).toEqual([]); + expect(targets('unrelatedExtension')).toEqual([]); + } finally { + cg?.close(); + fs.rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/__tests__/kotlin-infix-calls.test.ts b/__tests__/kotlin-infix-calls.test.ts index e95df7d31..c78a13695 100644 --- a/__tests__/kotlin-infix-calls.test.ts +++ b/__tests__/kotlin-infix-calls.test.ts @@ -128,13 +128,13 @@ infix fun Int.and(other: Int): Int = this fun numeric(value: Int, mask: Int) = (value) and mask `, 'numeric', []], // A parenthesized receiver is typed like a bare one, and an extension on a - // project class is not linked through a typed receiver (`value and mask` - // gives the same result), so no edge is better than a guess by name. + // project class links through the typed receiver (`value and mask` gives + // the same result): the extension is named `Int::and` whatever its package. ['project numeric extension', `package project class Int infix fun Int.and(other: Int): Int = this fun custom(value: Int, mask: Int) = (value) and mask -`, 'custom', []], +`, 'custom', ['Int::and']], ['nested-comment this receiver', `package app class Spec { infix fun apply(other: Spec): Spec = this diff --git a/codegraph-kernel/src/resolve/kotlin_receiver.rs b/codegraph-kernel/src/resolve/kotlin_receiver.rs index 99596c525..bae796fc1 100644 --- a/codegraph-kernel/src/resolve/kotlin_receiver.rs +++ b/codegraph-kernel/src/resolve/kotlin_receiver.rs @@ -133,6 +133,13 @@ impl KernelResolver { fn kotlin_receiver_member(&mut self, hit: &KotlinReceiverHit, name: &str, r: &ResolveRefIn) -> Res>> { if let Some(member) = self.match_bound_type_member(&hit.ty, name, &hit.site)? { return Ok(Some(member.node)); } + self.kotlin_extension_member(hit, name, r) + } + + /// The one visible extension function `name` whose receiver type accepts `hit`'s type: its + /// qualified name carries the receiver as written (`Foo::shout`, never `pkg::Foo::shout`), so + /// it cannot be found as a member of the receiver's class by qualified name. + pub(super) fn kotlin_extension_member(&mut self, hit: &KotlinReceiverHit, name: &str, r: &ResolveRefIn) -> Res>> { let candidates: Vec<_> = self.nodes_by_name(name)?.iter().filter(|n| n.language == "kotlin" && n.kind == "method").cloned().collect(); let mut matching = Vec::new(); for candidate in candidates { diff --git a/codegraph-kernel/src/resolve/method_call.rs b/codegraph-kernel/src/resolve/method_call.rs index 10d237465..bb6cbc48c 100644 --- a/codegraph-kernel/src/resolve/method_call.rs +++ b/codegraph-kernel/src/resolve/method_call.rs @@ -773,7 +773,15 @@ if inferred.is_none() && r.language == "dart" { if let Some(b) = &binding { bsite.line = b.line; } - return self.match_bound_type_member(&t, &method_name, &bsite); + let member = self.match_bound_type_member(&t, &method_name, &bsite)?; + if member.is_none() && r.language == "kotlin" { + // An extension function is no member of the receiver's class, and in a + // package its qualified name does not say which class it extends. + let hit = super::kotlin_receiver::KotlinReceiverHit { ty: t.clone(), site: bsite, heuristic: false }; + return Ok(self.kotlin_extension_member(&hit, &method_name, r)? + .map(|node| KCand { node, confidence: 0.9, resolved_by: "instance-method" })); + } + return Ok(member); } }