From 09a7e237d296da525a198f406e526f88ab4ca5e6 Mon Sep 17 00:00:00 2001 From: arttttt Date: Fri, 23 Oct 2026 17:58:16 -0500 Subject: [PATCH] fix(kotlin): a call on an imported type reaches that package's class when two share a name When several classes share a name, resolveMethodOnType picks the one the call file imports by matching the import's path against the candidate's file path (com/example/b/FooConverter.kt). That holds for Java, where a public class names its file, but a Kotlin class often lives in a file named otherwise (FooConverter in Converters.kt), so no candidate matched and the call went to whichever class came first. The imported name is now matched against each candidate's qualified name first: import com.example.b.FooConverter expects the method com.example.b::FooConverter::convert, which the package directive gives every JVM declaration whatever its file is called. The file-path match stays as the fallback. The Java calls this once fixed, a Java file calling a Kotlin class it imports (okhttp's Java tests and mockwebserver3.MockWebServer, SystemUI's BlurUtils), are now resolved on main by the receiver's declared type (#1942), so on today's main the change shows in Kotlin. From #539 we take only this change and its test. Its other two parts, indexing Kotlin class properties and reading Kotlin imports without a trailing semicolon, have since landed on main another way. Validation: - resolution.test.ts: the Kotlin same-name case (#314's shape in Converters.kt files) is red on main, green here. - A/B against main 40f58517 (with the Java receiver typing of #1942 and the Kotlin changes of #1933, #1761 and #2258): no edge changes on okhttp, SystemUI, nowinandroid, mihon, jextract, spring-modulith, Minestom, spring-ai, kestra-java, playframework, click or gin; none of them has the test's shape on a resolved call. Against main 4eb8c2a5, before #1942, it moved the 8 Java calls above onto the Kotlin class their file imports; main now reaches them at 0.9. - Cost (mihon, median of 3 interleaved indexes, on main 4eb8c2a5): CPU 25.7 s on main vs 25.2 s, wall 8.2 s vs 8.3 s. Original PR #539 by @arttttt. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/resolution.test.ts | 54 ++++++++++++++++++++++++++++++++++ src/resolution/name-matcher.ts | 17 +++++++++++ 3 files changed, 72 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4007a059d..82a703784 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -257,6 +257,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In Kotlin, a `fun interface` is now indexed as an interface with its methods, like okhttp's `HttpLoggingInterceptor.Logger`. Before, it was read as a broken function, and when a comment followed it, the declaration after it went missing too, its members turning into loose functions. Re-index Kotlin projects after upgrading. Thanks @danusha2345. - In Kotlin, a `private val` or `private var` property, and the members of a `private` class or object, no longer take calls from other files. Properties were indexed without their visibility, so a call like `token(...)` in one file could link to another class's `private var token`, and JavaScript, Go or Python callers could land on a Kotlin test's private property; a call to `now()` could also land on a method of a test's `private class Clock`. Re-index Kotlin projects after upgrading. Thanks @danusha2345. - 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: in the 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. Re-index Kotlin projects after upgrading. Thanks @danusha2345. +- In Kotlin, when two packages each declare a class with the same name, a call on a value of that type now links to the class the file imports, even when that class lives in a file named for something else, like `FooConverter` in `Converters.kt`. Before, it went to whichever of the two came first. Java calls on such a class, like okhttp's Java tests calling the `mockwebserver3.MockWebServer` they import, now link through the type their variable is declared with (#1942). Re-index Kotlin projects after upgrading. Thanks @arttttt. - In JavaScript and TypeScript, a `super.run()` call now links to the method of the class's parent, or of the nearest ancestor that has it, like three.js's `super.copy(source)` reaching `Object3D.copy`, so `codegraph_explore`'s call path and impact follow an override into the method it builds on. Before, such a call inside a method of the same name was dropped, and elsewhere it went to whatever method had the name, often the class's own or a sibling class's in the same file. A call from a static method reaches the parent's static method, and a call to a parent from outside your project, like `HTMLElement`, `Set` or `Promise`, links nothing. Re-index JavaScript and TypeScript projects after upgrading. Thanks @Puma7 for the report and the pull request. (#2497) - In JavaScript and TypeScript, a named class expression, like `register(class Widget extends Base {…})`, `customElements.define('x-card', class XCard extends HTMLElement {…})` or `const Panel = class PanelImpl extends Base {…}`, is now indexed as a class with its own methods and the class it extends, so calls between its methods and its `super` calls link as a class declaration's do. Before, its methods were free functions or weren't indexed at all, and it extended nothing. Its name stays bound inside the expression alone, as JavaScript has it: `new Widget()` elsewhere links nothing, a constant, class or interface of the same name around it keeps its links, and code outside never reaches its methods by a guess. A class a CommonJS module exports, like webpack's `module.exports = class MultiCompiler {…}` or `exports.Store = class Store {…}`, is reached through `require` as a class declaration is, along with its static methods and the methods called on its instances. Re-index JavaScript and TypeScript projects after upgrading. Thanks @Puma7 for the report and the pull request. (#2499) - In Go, a call through a struct field that holds a function, like prometheus's `d.refreshf(ctx)`, now reaches the functions put in that field, so `codegraph_explore` follows service discovery from `Discovery.Run` into each provider's `refresh` and names the line where each provider wires its method in, like `RefreshF: d.refresh`. The field can be set in a struct literal or by an assignment, copied from another struct's field (`refreshf: opts.RefreshF`), or given a method of an interface value, and two unrelated structs with a field of the same name, like `fn` or `handler`, stay apart. Before, such a flow stopped at the field. Re-index Go projects after upgrading. diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index 197ebd481..e20d2d997 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -1930,6 +1930,60 @@ 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. + 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/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index d08d38637..03ab98b31 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -13622,6 +13622,23 @@ export function resolveMethodOnType( if (chosen) return { original: ref, targetNodeId: chosen.id, confidence, resolvedBy }; } if (matches.length > 1 && preferredFqn && ref.language !== 'go' && !ES_IMPORT_LANGUAGES.has(ref.language)) { + // 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) => {