Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<PaginatedList<Document> items={…} />`, `User` in `useState<User>()` or `Entry` in a generic `<Entry extends BaseEntity>(…) =>` component, is no longer read as a tag. A tag naming a variable or parameter the component sets itself, like `<Content>` after `const Content = isDropdown ? DropdownMenu.SubContent : ContextMenu.SubContent` or `<Widget>` in `widgets.map((Widget) => <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

Expand Down
112 changes: 112 additions & 0 deletions __tests__/kotlin-extension-on-imported-type.test.ts
Original file line number Diff line number Diff line change
@@ -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']);
});
});
28 changes: 26 additions & 2 deletions src/resolution/name-matcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> = new Set([
'assert', 'error', 'ipairs', 'pairs', 'next', 'type', 'tostring', 'tonumber', 'setmetatable', 'getmetatable',
Expand Down Expand Up @@ -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
Expand Down