fix(kotlin): a call on an outside type reaches the project's extension on it

A Kotlin import from outside the project owns its name (#2196), so
Modifier.fillMaxSize() after import androidx.compose.ui.Modifier never
lands on a same-named project method. That rule returns before any other
strategy, so a project extension on the imported type, Compose's
fun Modifier.pad() (indexed as Modifier::pad), could not be reached by
Modifier.pad() either, nor Color.fromHex() on a
fun Color.Companion.fromHex().

Inside the rule, a Kotlin Type.member call now resolves to a project
extension Type::member that the calling file can see (same file or
package, or imported by name or with .*, as for other top-level Kotlin
declarations) and whose own file imports the same type. Anything else
stays unlinked, as before.

Validation:
- kotlin-extension-on-imported-type.test.ts: 2 cases, red on main,
  green here.
- A/B against main 13f983db (with the Kotlin receiver typing of #1933
  and #1761's private rule): mihon +26, all Compose Modifier
  extensions the project declares (secondaryItemAlpha 15,
  clickableNoIndication 5, selectedBackground 2, two scrollbars,
  keepInSection), nowinandroid +2 (Modifier.notificationDot,
  DeviceConfigurationOverride.WindowInsets), all right; okhttp,
  SystemUI, jextract, click and gin identical. The same sites as
  against main 4eb8c2a5, before #1933.
- Cost (mihon, median of 3 interleaved indexes, on main 4eb8c2a5): CPU
  25.7 s on main vs 25.6 s, wall 8.2 s vs 8.4 s.

Original PR #2258 by @danusha2345.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
danusha2345
2026-10-29 16:42:25 +00:00
committed by Colby Mchenry
co-authored by Claude Opus 5.5
parent 13f983db5a
commit 40f5851787
3 changed files with 139 additions and 2 deletions
+1
View File
@@ -256,6 +256,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- In Kotlin, a call through a property now links to the method of the type the property is declared with, like `server.enqueue(…)` on a `lateinit var server: MockWebServer` or a call on a constructor property such as `class A(private val repo: Repo)`. So does a call through a chain like `engine.pump.drain()` or `Mode.ON.next()`, through a property one of your classes inherits, through a local set from a call like `val lease = HardwareLock.tryBegin()`, and through a local of the function around an anonymous `object : …`. Before, such a call went to an interface's method or to whichever method had the name: on okhttp, the tests' calls reached the deprecated `MockWebServer` instead of the one each file imports. A call on a type from outside your project, like `Regex` or a JDK class, no longer links to a same-named method of your own, unless your project extends that type with it. Re-index Kotlin projects after upgrading. Thanks @danusha2345.
- In Kotlin, a `fun interface` is now indexed as an interface with its methods, like okhttp's `HttpLoggingInterceptor.Logger`. Before, it was read as a broken function, and when a comment followed it, the declaration after it went missing too, its members turning into loose functions. Re-index Kotlin projects after upgrading. Thanks @danusha2345.
- In Kotlin, a `private val` or `private var` property, and the members of a `private` class or object, no longer take calls from other files. Properties were indexed without their visibility, so a call like `token(...)` in one file could link to another class's `private var token`, and JavaScript, Go or Python callers could land on a Kotlin test's private property; a call to `now()` could also land on a method of a test's `private class Clock`. Re-index Kotlin projects after upgrading. Thanks @danusha2345.
- In Kotlin, a call on a library type you import, like Jetpack Compose's `Modifier.pad()`, now links to the extension your project declares on that type, `fun Modifier.pad()`, when the calling file can see it: in the same package, or imported by name or with `.*`. The library type's own methods, like `Modifier.fillMaxSize()`, still never link to a project method that shares the name. Re-index Kotlin projects after upgrading. Thanks @danusha2345.
- In JavaScript and TypeScript, a `super.run()` call now links to the method of the class's parent, or of the nearest ancestor that has it, like three.js's `super.copy(source)` reaching `Object3D.copy`, so `codegraph_explore`'s call path and impact follow an override into the method it builds on. Before, such a call inside a method of the same name was dropped, and elsewhere it went to whatever method had the name, often the class's own or a sibling class's in the same file. A call from a static method reaches the parent's static method, and a call to a parent from outside your project, like `HTMLElement`, `Set` or `Promise`, links nothing. Re-index JavaScript and TypeScript projects after upgrading. Thanks @Puma7 for the report and the pull request. (#2497)
- In JavaScript and TypeScript, a named class expression, like `register(class Widget extends Base {…})`, `customElements.define('x-card', class XCard extends HTMLElement {…})` or `const Panel = class PanelImpl extends Base {…}`, is now indexed as a class with its own methods and the class it extends, so calls between its methods and its `super` calls link as a class declaration's do. Before, its methods were free functions or weren't indexed at all, and it extended nothing. Its name stays bound inside the expression alone, as JavaScript has it: `new Widget()` elsewhere links nothing, a constant, class or interface of the same name around it keeps its links, and code outside never reaches its methods by a guess. A class a CommonJS module exports, like webpack's `module.exports = class MultiCompiler {…}` or `exports.Store = class Store {…}`, is reached through `require` as a class declaration is, along with its static methods and the methods called on its instances. Re-index JavaScript and TypeScript projects after upgrading. Thanks @Puma7 for the report and the pull request. (#2499)
- In Go, a call through a struct field that holds a function, like prometheus's `d.refreshf(ctx)`, now reaches the functions put in that field, so `codegraph_explore` follows service discovery from `Discovery.Run` into each provider's `refresh` and names the line where each provider wires its method in, like `RefreshF: d.refresh`. The field can be set in a struct literal or by an assignment, copied from another struct's field (`refreshf: opts.RefreshF`), or given a method of an interface value, and two unrelated structs with a field of the same name, like `fn` or `handler`, stay apart. Before, such a flow stopped at the field. Re-index Go projects after upgrading.
@@ -0,0 +1,112 @@
/**
* A Kotlin import from outside the project owns its name (#2196): after
* `import androidx.compose.ui.Modifier`, `Modifier.fillMaxSize()` is
* Compose's, never a project method that happens to be called `fillMaxSize`.
* But Kotlin lets the project extend that type — `fun Modifier.pad()`,
* indexed as `Modifier::pad` — and `Modifier.pad()` is that extension when
* the call site can see it: in its own package, or imported by name or `.*`.
* `fun Color.Companion.fromHex()` is called the same way, `Color.fromHex()`.
*/
import { describe, it, expect, afterAll, beforeAll } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
let root = '';
let cg: CodeGraph;
beforeAll(async () => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-kotlin-ext-imported-'));
const files: Record<string, string> = {
'src/main/kotlin/app/ui/Ext.kt': `package app.ui
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
fun Modifier.pad(): Modifier = this
fun Modifier.hidden(): Modifier = this
fun Color.Companion.fromHex(hex: String): Color = Color.Red
`,
'src/main/kotlin/app/other/Box.kt': `package app.other
class Box {
fun fillMaxSize(): Box = this
}
`,
'src/main/kotlin/app/gen/Gen.kt': `package app.gen
import javax.lang.model.element.Modifier
fun Modifier.keyword(): String = name
`,
'src/main/kotlin/app/ui/Screen.kt': `package app.ui
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
fun screen() {
Modifier.pad()
Modifier.fillMaxSize()
Color.fromHex("#fff")
}
`,
'src/main/kotlin/app/feature/Feed.kt': `package app.feature
import androidx.compose.ui.Modifier
import app.gen.keyword
import app.ui.pad
fun feed() {
Modifier.pad()
Modifier.hidden()
Modifier.keyword()
}
`,
'src/main/kotlin/app/settings/Settings.kt': `package app.settings
import androidx.compose.ui.Modifier
import app.ui.*
fun settings() {
Modifier.hidden()
}
`,
};
for (const [rel, content] of Object.entries(files)) {
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
fs.writeFileSync(path.join(root, rel), content);
}
cg = await CodeGraph.init(root, { index: true });
});
afterAll(() => {
cg?.close();
if (root) fs.rmSync(root, { recursive: true, force: true });
});
/** `kind qualified-name` of every non-structural edge out of a file. */
function edgesFrom(file: string): string[] {
const ids = cg.getNodesInFile(file).map((n) => n.id);
return cg
.getOutgoingEdgesFrom(ids)
.filter((e) => e.kind !== 'contains' && e.kind !== 'imports')
.map((e) => `${e.kind} ${cg.getNode(e.target)!.qualifiedName}`)
.sort();
}
describe('Kotlin: a project extension on a type an outside import names', () => {
it('is what a call on that type reaches from the same package; the type’s own members stay outside', () => {
expect(edgesFrom('src/main/kotlin/app/ui/Screen.kt')).toEqual([
'calls Color::fromHex',
'calls Modifier::pad',
]);
});
it('is reached from another package only through an import of it, by name or `.*`', () => {
expect(edgesFrom('src/main/kotlin/app/feature/Feed.kt')).toEqual(['calls Modifier::pad']);
expect(edgesFrom('src/main/kotlin/app/settings/Settings.kt')).toEqual(['calls Modifier::hidden']);
});
});
+26 -2
View File
@@ -1966,6 +1966,29 @@ export function isJavaOutsideImport(name: string, ref: UnresolvedRef, context: R
return true;
}
/**
* `Modifier.pad()` after `import androidx.compose.ui.Modifier`: the type is
* outside the project, but Kotlin lets the project extend it. The call reaches
* a project extension on that type — `fun Modifier.pad()`, indexed as
* `Modifier::pad` — that the call site can see (its file, its package, an
* import of it by name or `.*`) and whose own file imports the same type.
* Compose's own `Modifier.fillMaxSize()` matches none and stays unlinked.
*/
function kotlinExtensionOnImportedType(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
const call = /^(\w+)\.(\w+)$/.exec(ref.referenceName);
if (!call || ref.referenceKind !== 'calls') return null;
const binding = context.getImportMappings(ref.filePath, ref.language).find((m) => m.localName === call[1]);
if (!binding) return null;
const pkg = binding.source.slice(0, binding.source.lastIndexOf('.'));
const extensions = context.getNodesByQualifiedName(`${binding.exportedName}::${call[2]}`).filter((n) => {
if (n.kind !== 'method' || n.language !== 'kotlin' || !isKotlinTopLevelVisible(n, ref, context)) return false;
const scope = kotlinFileScope(n.filePath, context);
return scope.imports.has(binding.source) || scope.stars.has(pkg);
});
const chosen = extensions.length > 1 ? extensions.filter((n) => n.filePath === ref.filePath) : extensions;
return chosen.length === 1 ? { original: ref, targetNodeId: chosen[0]!.id, confidence: 0.9, resolvedBy: 'qualified-name' } : null;
}
/** Lua's global functions, and the test runner's: `local type = type` is the standard library's `type`. */
const LUA_GLOBAL_FUNCTIONS: ReadonlySet<string> = new Set([
'assert', 'error', 'ipairs', 'pairs', 'next', 'type', 'tostring', 'tonumber', 'setmetatable', 'getmetatable',
@@ -28885,10 +28908,11 @@ function matchReferenceInner(
// `import java.lang.reflect.Field;` — the file's `Field` is the JDK's, never
// a project class of that name (gson's production code bound it to a test's
// nested `ParameterizedTypesTest.Field`).
// nested `ParameterizedTypesTest.Field`). Only a Kotlin extension the
// project declares on that type is in the graph.
if ((ref.language === 'java' || ref.language === 'kotlin') && ref.referenceKind !== 'imports' &&
isJavaOutsideImport(ref.referenceName.split('.')[0]!, ref, context)) {
return null;
return ref.language === 'kotlin' ? kotlinExtensionOnImportedType(ref, context) : null;
}
// A symbolic name in a Scala type is a type (`F ~> G`) or a kind-projector