From 48d1d192d44061def8f541a73284fa34d66649e7 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 a9d37c0303..ed78618cba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -97,6 +97,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In JavaScript and TypeScript, an import written `import { default as AppRoot } from './routes/app/root'`, as in bulletproof-react's router, now counts as the module's default import, the same as `import AppRoot from './routes/app/root'`. Before, it was read as an import of an export named `default`, which no module has, so a call, a route or a JSX attribute that used `AppRoot` was matched by its name alone: it could link to another file's `AppRoot`, or to nothing when the default export has a name of its own. Svelte, Vue and Astro script blocks are read the same way. - In React, a component's JSX no longer links it to a class or component that only shares a name with one of its type arguments or with a variable of its own. A type in angle brackets, like `Document` in ` items={…} />`, `User` in `useState()` or `Entry` in a generic `(…) =>` component, is no longer read as a tag. A tag naming a variable or parameter the component sets itself, like `` after `const Content = isDropdown ? DropdownMenu.SubContent : ContextMenu.SubContent` or `` in `widgets.map((Widget) => )`, now links to nothing, unless the component declares a component of that name inside itself. Before, these linked to an unrelated class or component elsewhere in the repository: outline's document lists showed up among the callers of its `Document` model class, and its menus among the callers of the command bar's `Content`, so `codegraph_explore`, callers and impact followed renders that never happen. Re-index React projects after upgrading. - In JavaScript and TypeScript, a name imported from a package your `package.json` lists no longer links to a project symbol that only shares its name when `tsconfig.json` or `jsconfig.json` has a catch-all path alias, like `"*": ["./typings/*"]` or `"*": ["src/*", "node_modules/*"]`, or an alias that points the package at a file in `node_modules`, like `"lit/decorators": ["./node_modules/lit/decorators.js"]`. Such an alias made every package look like part of the project, so `import { Typography } from '@mui/material'` was linked to the project's own `Typography` and every lit `@property()` decorator to an unrelated class's `property` field, and `codegraph callers`, impact and `codegraph affected` listed code that never used them. An import the alias does map to a file of your project, like a `.d.ts` you keep for an untyped package or `components/Button` through `"*": ["src/*"]`, links as before. Re-index affected projects after upgrading. +- 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. ## [1.6.2] - 2026-10-03 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 0934c2844d..edf5e1fa8f 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -1647,6 +1647,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', @@ -13284,10 +13307,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