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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- In JavaScript and TypeScript, a default import now links to what the module's `export default` statement exports, wherever the statement sits in the file. Before, a module that declares its default, like `export default function Vans() {…}` or `export default class Store {…}`, had its default import linked to the first function it exports instead: in a React Router data-router app, where a page exports its `loader` or `action` above the page component, `import Vans, { loader as vansLoader } from './pages/Vans'` made the route `/vans` and every use of `Vans` lead to the loader. A component file that also exports a styled or memoized component no longer hands its default import to that component, and an anonymous default, like `export default function () {…}` or `export default () => …`, is no longer linked to a function exported beside it or nested inside it. A module loaded with `require('./x').default` is read the same way. Re-index JavaScript and TypeScript projects after upgrading.
- In React and Vue, a tag for a component the file imports from a package, like `<Button>` from antd, `<Typography>` from `@mui/material/Typography`, `<Link>` from react-router-dom or `<Card>` from ant-design-vue, no longer links to a component, function or class in your repository that only shares its name. Before, the tag linked to whatever the repository had by that name, so `codegraph_explore`, callers and impact followed renders that never happen: in SigNoz, antd's `<Button>` tags showed up as callers of a styled link on its 404 page, and in Material UI dashboards like mantis and berry, `<Typography>` and `<Button>` tags showed up as callers of the theme's style overrides. A package here is one that a `package.json` above the file lists and that no file in the repository answers to. A tag imported through a path alias like `components/Modal`, from a workspace package of the repository, or under a name no `package.json` lists still links as before. Re-index React and Vue projects after upgrading.
- In React with TypeScript, a component that renders a generic component and passes it type arguments, like outline's `<PaginatedList<Document> items={…} />` or excalidraw's `<DropdownMenuItemContentRadio<"contain" | "overlap"> …>`, 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.
- Kotlin calls through imported types now avoid unrelated same-named methods while retaining inherited members and project extension functions. (#1948)


## [1.6.2] - 2026-10-03

Expand Down
90 changes: 90 additions & 0 deletions __tests__/kotlin-imported-type-call.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { describe, expect, it } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import CodeGraph from '../src/index';

describe('Kotlin calls through explicit imports', () => {
it('uses the imported type, while retaining values, extensions and nested constructors', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-kotlin-import-'));
let cg: CodeGraph | undefined;
try {
for (const [file, source] of Object.entries({
'p/Outer.java': `package p;
public class Outer { public static class Inner { public Inner() {} } }`,
'p/Factories.kt': `package p
object Factory { fun create(): Int = 2 }
class ExtFactory { companion object }
open class Base { fun hello() {} }
object Obj : Base()
object Modes
object Dimensions { val Height = 2 }
fun Int.toPx(): Int = this
class UnrelatedFactory { fun create(): Int = 1 }`,
'p/Ext.kt': `package p
import androidx.compose.ui.Modifier
fun ExtFactory.Companion.extMake() {}
fun Modes.extOther() {}
fun Modes.hidden() {}
fun Modifier.pad() {}`,
'q/Calls.kt': `package q
import android.app.TaskStackBuilder // external type
import androidx.savedstate.SavedStateRegistryController
import p.Factory as LocalFactory
import p.Factory
import p.ExtFactory // trailing comment
import p.Obj
import p.Modes
import p.extMake
import p.extOther
import p.pad
import androidx.compose.ui.Modifier
import p.Dimensions.Height
import p.toPx
import p.Outer
fun Modes.ext() {}
class Calls {
fun android() { TaskStackBuilder.create(null) }
fun androidx() { SavedStateRegistryController.create(this) }
fun aliased() { LocalFactory.create() }
fun direct() { Factory.create() }
fun extension() { Modes.ext() }
fun value() { Height.toPx() }
fun nested() { Outer.Inner() }
fun companionExtension() { ExtFactory.extMake() }
fun inherited() { Obj.hello() }
fun externalExtension() { Modifier.pad() }
fun crossFileExtension() { Modes.extOther() }
fun unimportedExtension() { Modes.hidden() }
}`,
})) {
const target = path.join(dir, file);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, source);
}
cg = CodeGraph.initSync(dir);
await cg.indexAll();
const callees = (name: string) => {
const caller = cg!.searchNodes(name).map(r => r.node)
.find(n => n.qualifiedName === `q::Calls::${name}`)!;
return cg!.getCallees(caller.id).filter(c => c.edge.kind === 'calls')
.map(c => c.node.qualifiedName);
};
expect(callees('android')).toEqual([]);
expect(callees('androidx')).toEqual([]);
expect(callees('aliased')).toEqual(['p::Factory::create']);
expect(callees('direct')).toEqual(['p::Factory::create']);
expect(callees('extension')).toEqual(['Modes::ext']);
expect(callees('value')).toEqual(['Int::toPx']);
expect(callees('nested')).toEqual(['p::Outer::Inner::Inner']);
expect(callees('companionExtension')).toEqual(['ExtFactory::extMake']);
expect(callees('inherited')).toEqual(['p::Base::hello']);
expect(callees('externalExtension')).toEqual(['Modifier::pad']);
expect(callees('crossFileExtension')).toEqual(['Modes::extOther']);
expect(callees('unimportedExtension')).toEqual([]);
} finally {
cg?.destroy();
fs.rmSync(dir, { recursive: true, force: true });
}
});
});
79 changes: 78 additions & 1 deletion src/resolution/name-matcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10015,6 +10015,34 @@ function importedFqnOf(
return imports.find((i) => i.localName === typeName)?.source;
}

function kotlinImportedType(name: string, ref: UnresolvedRef, context: ResolutionContext): string | undefined {
// ponytail: scan explicit imports here; cache per file only if indexing profiles justify it.
const source = context.readFile(ref.filePath);
if (!source) return undefined;
for (const match of source.matchAll(/^[ \t]*import[ \t]+([\w.]+)(?:[ \t]+as[ \t]+(\w+))?[ \t]*;?[ \t]*(?:\/\/[^\n]*)?$/gm)) {
const fqn = match[1]!;
if ((match[2] || fqn.split('.').pop()) !== name) continue;
const importedNode = context.getNodesByName(fqn.split('.').pop()!).find(n =>
n.qualifiedName.replace(/::/g, '.') === fqn);
if (importedNode && !['class', 'interface', 'enum', 'type_alias'].includes(importedNode.kind)) return undefined;
return fqn;
}
return undefined;
}

function kotlinExtensionInScope(target: Node, ref: UnresolvedRef, context: ResolutionContext): boolean {
if (target.filePath === ref.filePath) return true;
const declaration = context.readFile(target.filePath) ?? '';
const caller = context.readFile(ref.filePath) ?? '';
const targetPackage = /^\s*package\s+([\w.]+)/m.exec(declaration)?.[1] ?? '';
const callerPackage = /^\s*package\s+([\w.]+)/m.exec(caller)?.[1] ?? '';
if (targetPackage === callerPackage) return true;
for (const match of caller.matchAll(/^[ \t]*import[ \t]+([\w.]+(?:\.\*)?)(?:[ \t]+as[ \t]+\w+)?[ \t]*(?:\/\/[^\n]*)?$/gm)) {
if (match[1] === `${targetPackage}.${target.name}` || match[1] === `${targetPackage}.*`) return true;
}
return false;
}

/**
* Java/Kotlin: infer a receiver's declared type by walking field declarations
* in the class enclosing the call site. The field's `signature` is already in
Expand Down Expand Up @@ -11344,6 +11372,44 @@ export function matchMethodCall(
}

const [, objectOrClass, methodName] = match;
// An explicit Kotlin type import fixes the receiver's identity. Resolve
// only that type (including Java interop), never a same-named local method.
const importedType = ref.language === 'kotlin' && dotMatch && /^[A-Z]\w*$/.test(objectOrClass!)
? kotlinImportedType(objectOrClass!, ref, context) : undefined;
if (importedType) {
const localExtension = context.getNodesInFile(ref.filePath).find(n =>
n.kind === 'method' && n.qualifiedName === `${objectOrClass}::${methodName}`);
if (localExtension) return { original: ref, targetNodeId: localExtension.id,
confidence: 0.9, resolvedBy: 'qualified-name' };
const simpleName = importedType.split('.').pop()!;
const owners = context.getNodesByName(simpleName).filter(n =>
(n.kind === 'class' || n.kind === 'interface' || n.kind === 'enum') &&
(n.language === 'kotlin' || n.language === 'java') &&
n.qualifiedName.replace(/::/g, '.') === importedType);
const methods = owners.flatMap(owner => context.getNodesInFile(owner.filePath).filter(n =>
n.kind === 'method' && (n.language === 'kotlin' || n.language === 'java') &&
(n.qualifiedName === `${owner.qualifiedName}::${methodName}` ||
n.qualifiedName === `${owner.qualifiedName}::Companion::${methodName}`)));
if (owners.length === 1 && methods.length === 0) {
const constructor = context.getNodesInFile(owners[0]!.filePath).find(n =>
n.kind === 'method' &&
n.qualifiedName === `${owners[0]!.qualifiedName}::${methodName}::${methodName}`);
if (constructor) return { original: ref, targetNodeId: constructor.id,
confidence: 0.9, resolvedBy: 'qualified-name' };
}
if (owners.length === 1 && methods.length > 0) return { original: ref, targetNodeId: methods[0]!.id,
confidence: 0.9, resolvedBy: 'qualified-name' };
const extensions = context.getNodesByName(methodName!).filter(n =>
n.kind === 'method' && n.language === 'kotlin' &&
n.qualifiedName === `${objectOrClass}::${methodName}`);
if (extensions.length > 0) {
const visible = extensions.filter(n => kotlinExtensionInScope(n, ref, context));
if (visible.length === 1) return { original: ref, targetNodeId: visible[0]!.id,
confidence: 0.9, resolvedBy: 'qualified-name' };
return null;
}
if (owners.length === 0) return null;
}
// A simple `receiver.method` / `receiver:method` / `receiver$method` shape whose
// receiver type we can try to infer from its local declaration.
const inferableReceiver = dotMatch || luaColonMatch || rDollarMatch;
Expand Down Expand Up @@ -14034,7 +14100,9 @@ function matchReferenceInner(
// nested `ParameterizedTypesTest.Field`).
if ((ref.language === 'java' || ref.language === 'kotlin') && ref.referenceKind !== 'imports' &&
isJavaOutsideImport(ref.referenceName.split('.')[0]!, ref, context)) {
return null;
// Kotlin can call an in-project extension on an imported library type.
return ref.language === 'kotlin' && ref.referenceKind === 'calls'
? matchMethodCall(ref, context) : null;
}

// A symbolic name in a Scala type is a type (`F ~> G`) or a kind-projector
Expand Down Expand Up @@ -14230,6 +14298,15 @@ function matchReferenceInner(
// 2. Method call pattern
result = nmTimed('methodCall', ref, () => matchMethodCall(ref, context));
if (result) return result;
if (ref.language === 'kotlin' && ref.referenceKind === 'calls') {
const importedReceiver = ref.referenceName.match(/^([A-Z]\w*)\.\w+$/)?.[1];
if (importedReceiver) {
const importedType = kotlinImportedType(importedReceiver, ref, context);
if (importedType && !context.getNodesByName(importedType.split('.').pop()!).some(n =>
(n.kind === 'class' || n.kind === 'interface' || n.kind === 'enum') &&
n.qualifiedName.replace(/::/g, '.') === importedType)) return null;
}
}

// 3. Exact name match
result = nmTimed('exactName', ref, () => matchByExactName(ref, context));
Expand Down