diff --git a/CHANGELOG.md b/CHANGELOG.md index 21b31bb0a..f76e97f71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes - Indexing a project that contains only config-style files (YAML, Twig, or `.properties`) no longer misleadingly reports "No files found to index" — these files are tracked at the file level and are now counted as indexed. Thanks @luojiyin1987 (#357). +- Kotlin calls through a field receiver (`fooConverter.convert(...)`) now resolve to the class named by the file's `import`, even when two packages declare a class with the same name (`com.example.a.FooConverter` vs `com.example.b.FooConverter`) — previously the call landed on whichever class was found first, ignoring the import. Two gaps caused it: Kotlin class properties weren't being indexed at all (so the receiver's type was invisible), and Kotlin's import lines were skipped because the parser required a trailing `;` that Kotlin doesn't use. Both are fixed, and disambiguation now matches the class's package-qualified name rather than assuming the class lives in a file named after it (Kotlin commonly puts `FooConverter` in `Converters.kt`). The equivalent Java case already worked; this brings Kotlin to parity (#314). ## [0.9.7] - 2026-05-28 diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index b497af6a9..830414cb6 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -1323,6 +1323,38 @@ fun util(): Int = 42 expect(util?.qualifiedName).toBe('com.example.foo::util'); }); + it('extracts class properties with a typed field signature', () => { + // Kotlin `property_declaration` parses as variable_declaration → + // simple_identifier (+ user_type), not the `variable_declarator` shape + // Java/C# use, so before the fix class properties produced NO field node + // at all — which broke receiver-type inference for `field.method()` calls + // (#314). + const code = ` +package com.example.web + +class Handler { + private val fooConverter: FooConverter = FooConverter() + var count: Int = 0 + private val nullable: Bar? = null +} +`; + const result = extractFromSource('Handler.kt', code); + + const fooField = result.nodes.find((n) => n.kind === 'field' && n.name === 'fooConverter'); + expect(fooField).toBeDefined(); + // Signature must be " " so inferJavaFieldReceiverType reads it. + expect(fooField?.signature).toBe('FooConverter fooConverter'); + expect(fooField?.qualifiedName).toBe('com.example.web::Handler::fooConverter'); + + const countField = result.nodes.find((n) => n.kind === 'field' && n.name === 'count'); + expect(countField?.signature).toBe('Int count'); + + // Kotlin nullable `Bar?` is normalized to `Bar` so it resolves like the + // non-null type. + const nullableField = result.nodes.find((n) => n.kind === 'field' && n.name === 'nullable'); + expect(nullableField?.signature).toBe('Bar nullable'); + }); + it('handles a single-segment package', () => { const code = ` package foo diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index 03b8ea6ab..7a53e564d 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -971,6 +971,64 @@ public class Handler { ); }); + it('Kotlin import disambiguates same-name classes in non-eponymous files (#314)', async () => { + // The Kotlin twist on #314: the class lives in a file NOT named after + // it (`Converters.kt`, the idiomatic Kotlin layout), so the file-path + // suffix heuristic (`com/example/b/FooConverter.kt`) can't match — only + // the namespace `qualifiedName` (`com.example.b::FooConverter`, from the + // package directive) disambiguates. Two prerequisites had to hold: + // (1) the `val fooConverter: FooConverter` property must extract as a + // field so the receiver type is known (Kotlin property extraction); + // (2) resolveMethodOnType must prefer the candidate whose qn matches the + // imported FQN, not the path suffix. + const aDir = path.join(tempDir, 'module-a/src/main/kotlin/com/example/a'); + const bDir = path.join(tempDir, 'module-b/src/main/kotlin/com/example/b'); + const webDir = path.join(tempDir, 'web/src/main/kotlin/com/example/web'); + fs.mkdirSync(aDir, { recursive: true }); + fs.mkdirSync(bDir, { recursive: true }); + fs.mkdirSync(webDir, { recursive: true }); + + fs.writeFileSync( + path.join(aDir, 'Converters.kt'), + `package com.example.a +class FooConverter { fun convert(x: String): String { return "a:" + x } } +` + ); + fs.writeFileSync( + path.join(bDir, 'Converters.kt'), + `package com.example.b +class FooConverter { fun convert(x: String): String { return "b:" + x } } +` + ); + // Caller imports the module-b converter; module-a is lexically first in + // the candidate list, so a correct result proves the import wins. + fs.writeFileSync( + path.join(webDir, 'Handler.kt'), + `package com.example.web + +import com.example.b.FooConverter + +class Handler { + private val fooConverter: FooConverter = FooConverter() + fun use(): String { return fooConverter.convert("input") } +} +` + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + + const use = cg + .getNodesByKind('method') + .find((n) => n.qualifiedName.endsWith('Handler::use')); + expect(use).toBeDefined(); + const calls = cg.getOutgoingEdges(use!.id).filter((e) => e.kind === 'calls'); + expect(calls.length).toBeGreaterThanOrEqual(1); + + const target = cg.getNode(calls[0]!.target); + expect(target?.name).toBe('convert'); + expect(target?.qualifiedName).toBe('com.example.b::FooConverter::convert'); + }); + it('C# extracts references from method/property/field types (#381)', async () => { // Pre-#381, every C# project produced ZERO `references` edges: // csharp.ts was missing returnField, and the type-leaf walker diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index f576839fa..abea3e22b 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -1053,6 +1053,42 @@ export class TreeSitterExtractor { } } + // Kotlin property_declaration: `private val x: Foo = ...` parses as + // variable_declaration → simple_identifier (name) + user_type (type). + // It reuses the `variable_declaration` wrapper C# uses but holds a bare + // `simple_identifier`, not a `variable_declarator`, so neither branch + // above matches and the identifier-only fallback below misses it too + // (Kotlin's grammar has no field names). Without this branch Kotlin class + // properties produce NO node, so receiver-type inference can't see the + // field's declared type and same-name cross-package calls mis-resolve (#314). + if (declarators.length === 0 && this.language === 'kotlin') { + const varDecl = node.namedChildren.find(c => c.type === 'variable_declaration'); + const nameNode = varDecl?.namedChildren.find(c => c.type === 'simple_identifier'); + if (varDecl && nameNode) { + const name = getNodeText(nameNode, this.source); + // The type sits as a sibling of the name inside variable_declaration. + // Emit a " " signature (matching extractField's Java/C# + // shape) so inferJavaFieldReceiverType can read the declared type; + // strip Kotlin's nullable `?` so `Foo?` resolves like `Foo`. + const typeNode = varDecl.namedChildren.find(c => c.type !== 'simple_identifier'); + const typeText = typeNode + ? getNodeText(typeNode, this.source).replace(/\?$/, '').trim() + : undefined; + const signature = typeText ? `${typeText} ${name}` : name; + const fieldNode = this.createNode('field', name, node, { + docstring, + signature, + visibility, + isStatic, + }); + if (fieldNode) { + this.extractDecoratorsFor(node, fieldNode.id); + this.extractTypeAnnotations(node, fieldNode.id); + } + return; + } + } + if (declarators.length > 0) { // Get field type from the type child // Java: type is a direct child of field_declaration diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts index bc493704d..cf8ddea7e 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -733,8 +733,14 @@ function extractJavaImports(content: string): ImportMapping[] { const stripped = content .replace(/\/\*[\s\S]*?\*\//g, '') .replace(/\/\/[^\n]*/g, ''); - // `import [static] [.*];` - const re = /^\s*import\s+(static\s+)?([\w.]+(?:\.\*)?)\s*;/gm; + // `import [static] [.*][;]` — the trailing `;` is optional because + // Kotlin imports omit it (`import com.example.Foo`). Without this, Kotlin + // produced ZERO import mappings, so receiver-FQN disambiguation (the + // `importedFqn` passed to resolveMethodOnType) never fired for Kotlin and + // same-name classes fell back to lexical order (#314). A bare `import …` with + // a Kotlin `as Alias` suffix still captures the FQN (alias unhandled, as in + // the Java path). Java's mandatory `;` keeps matching unchanged. + const re = /^\s*import\s+(static\s+)?([\w.]+(?:\.\*)?)\s*;?/gm; let match: RegExpExecArray | null; while ((match = re.exec(stripped)) !== null) { const fqn = match[2]!; diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 03fa79242..275e4717a 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -182,6 +182,23 @@ function resolveMethodOnType( if (matches.length === 0) return null; if (matches.length > 1 && preferredFqn) { + // Prefer the candidate whose namespace-qualified name matches the imported + // FQN. #412 indexes JVM declarations as `::` from the file's + // `package` directive — independent of the file name — so this disambiguates + // Kotlin (where `FooConverter` can live in `Converters.kt`) that the + // file-path-suffix heuristic below can't. `com.example.b.FooConverter` + // → expected method qn `com.example.b::FooConverter::convert`. + const lastDot = preferredFqn.lastIndexOf('.'); + if (lastDot > 0) { + const expectedQn = `${preferredFqn.slice(0, lastDot)}::${preferredFqn.slice(lastDot + 1)}::${methodName}`; + const byQn = matches.find((m) => m.qualifiedName === expectedQn); + if (byQn) { + return { original: ref, targetNodeId: byQn.id, confidence, resolvedBy }; + } + } + // Fall back to file-path-suffix match for Java's class-name = file-name + // layout (covers files indexed before the namespace wrapper, or any case + // the qn match misses). const ext = ref.language === 'kotlin' ? '.kt' : '.java'; const fqnPath = preferredFqn.replace(/\./g, '/') + ext; const chosen = matches.find((m) => {