From df52d431542e84a743dfbd97bc7b812f80e3dfb4 Mon Sep 17 00:00:00 2001 From: danusha2345 Date: Thu, 1 Oct 2026 19:30:06 +0300 Subject: [PATCH] fix(kotlin): a call on an outside type reaches the project's extension on it #2196 made a Kotlin import from outside the project own its name, so `Modifier.fillMaxSize()` after `import androidx.compose.ui.Modifier` no longer lands on a same-named project method. The rule returns before any other strategy, so a project extension on that 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 call site can see (same file or package, or imported by name or `.*`, as for other top-level Kotlin declarations) and whose own file imports the same type. Anything else stays unlinked, as before. The visibility check follows the idea of `kotlinExtensionInScope` from the discussion in #1948. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + .../kotlin-extension-on-imported-type.test.ts | 112 ++++++++++++++++++ src/resolution/name-matcher.ts | 28 ++++- 3 files changed, 139 insertions(+), 2 deletions(-) create mode 100644 __tests__/kotlin-extension-on-imported-type.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 363afd45e5..b3b82be5a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- 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: 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. - In TypeScript, an awaited call with type arguments like `await fetchApi("/me")` or `await api.get(url)` now links to the function it calls. Before, the call was dropped, so typed fetch wrappers looked like they had no callers. (#2586) - In React, a component rendered at module scope, in a top-level constant or inside a test's `it(…)` callback, like `createRoot(el).render()` or `render()`, now counts as rendered there, so an app's entry reaches its root component and a component's tests show among its callers. (#2587) - In Python, `import a.b as X` written inside a function now links its calls like `X.f()`, as the same import at module level already did. Thanks @Egrn for the report. (#2600) diff --git a/__tests__/kotlin-extension-on-imported-type.test.ts b/__tests__/kotlin-extension-on-imported-type.test.ts new file mode 100644 index 0000000000..70660b6b40 --- /dev/null +++ b/__tests__/kotlin-extension-on-imported-type.test.ts @@ -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 = { + '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']); + }); +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index e76a5b6cca..9a2cbcae6f 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -1946,6 +1946,29 @@ function isJavaOutsideImport(name: string, ref: UnresolvedRef, context: Resoluti 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 = new Set([ 'assert', 'error', 'ipairs', 'pairs', 'next', 'type', 'tostring', 'tonumber', 'setmetatable', 'getmetatable', @@ -24279,10 +24302,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