diff --git a/CHANGELOG.md b/CHANGELOG.md index 4eb9e73572..05b0e16d7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -154,6 +154,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In React with TypeScript, a component that renders a generic component and passes it type arguments, like outline's ` items={…} />` or excalidraw's ` …>`, now links to the component it renders. Before, a tag followed by type arguments was skipped, so the generic component looked unused by the screens that render it, and `codegraph_explore`'s call paths, callers and impact stopped short of it. Re-index React projects after upgrading. - In React, a component that a file declares itself as a value is now what that file's JSX renders, whatever else in the repository shares its name. A lazily loaded page, like `const RegisterPage = Loadable(lazy(() => import('./pages/Register')))`, a `dynamic(() => import(…))` component or one loaded only in development (`IS_DEV ? lazy(…) : null`), links to the component its module exports, including the one its loader picks and one an `export default observer(Login)` wraps; a wrapped component like `const Settings = observer(function Settings() {…})`, `React.memo(Rows, areEqual)` or `forwardRef(ComboboxInner) as {…}` links to the function it wraps; and an alias like `const DialogPortal = DialogPrimitive.Portal` links to its own declaration. Before, `` was linked to any lone component of that name elsewhere, often in another app of the same repository, so `codegraph_explore`, callers and impact followed it into the wrong app while the page it really renders had no caller. React Router layouts declared this way follow the same rule, and a TSX type named like a value the file declares now stays in that file. Re-index React projects after upgrading. - In React, a JSX tag imported from one of your own modules now links to the component that module exports under that name, read the way a lazily loaded page's module already is. That includes going through an `index` file or barrel that passes the component on, like outline's `Sidebar/index.ts` with `import Sidebar from './App'; export default Sidebar` or its `Avatar/index.ts` with `const Avatar = AvatarWithHoverCard`, and past a wrapper such as `export default observer(AppSidebar)`, `React.memo(StaticCanvas, areEqual)` or mastodon's `const MemoizedBlurhash = memo(Blurhash)`. Before, a tag was matched by its name and the import only broke ties, so outline's `` linked to an unrelated styled `Sidebar` in a sibling file, a default import under another name linked to whatever other component its module exported, and an import through a barrel could land on another app's component of the same name. A tag whose module exports an unnamed component, like `export default () => …`, now links to nothing instead of to a namesake elsewhere. A lazily loaded React Router page exported as such a value or wrapper links to the component it holds too, the one with the markup. `codegraph sync` and the file watcher redraw these links when only the file that passes the component on changes. Re-index React 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 2726405ffa..f37cdd0bf8 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -1654,6 +1654,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', @@ -16132,10 +16155,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