From 7ff8c0824f42e2168df31896514ae93b8408c3ea Mon Sep 17 00:00:00 2001 From: danusha2345 Date: Thu, 24 Sep 2026 11:56:40 +0300 Subject: [PATCH 01/15] fix(kotlin): resolve property receivers on their declared type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit inferJavaFieldReceiverType reads a field's type from its signature, in the Java shape `Type name`. Kotlin properties are indexed without a signature, and primary-constructor properties (`class A(private val repo: Repo)`) are not indexed at all, so every Kotlin `prop.method()` got no type and fell through to name-only guessing: the interface method, or any same-named method elsewhere (`probe.close()` on a `lateinit var probe: HttpProbe` resolved to an unrelated class's `close`). For Kotlin the type is now read from the declaration: the property's own lines (`name: Type`, or `name = Type(…)`), or the class header before its first member for a constructor property. A nested type keeps its outer type (`HardwareLock.Lease` → `HardwareLock::Lease`), so it matches that `Lease` and not another class's. resolveMethodOnType still validates the method. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 + __tests__/kotlin-property-receiver.test.ts | 104 +++++++++++++++++++++ src/resolution/name-matcher.ts | 59 ++++++++++++ 3 files changed, 165 insertions(+) create mode 100644 __tests__/kotlin-property-receiver.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 06ca319d98..14a941988d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -262,6 +262,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - The Claude Code prompt hook no longer runs on the task-notification messages Claude Code injects when a background agent finishes, removing a multi-second stall on every such turn. (#1832) +- Kotlin calls through a class property, including a primary-constructor property, now resolve on the property's declared type instead of a same-named method on the interface or on an unrelated class. Re-index Kotlin projects to pick this up. + - Rust calls on `self` now stay with the enclosing type instead of linking to an unrelated type’s same-named method. Thanks @L4XB. (#1861) - Turning telemetry off now resets its identity and stops running processes from recording, sending, or restoring unsent data. (#1869) diff --git a/__tests__/kotlin-property-receiver.test.ts b/__tests__/kotlin-property-receiver.test.ts new file mode 100644 index 0000000000..5ebd2bc645 --- /dev/null +++ b/__tests__/kotlin-property-receiver.test.ts @@ -0,0 +1,104 @@ +/** + * A call through a Kotlin property resolves on the property's declared type. + * + * Kotlin properties are indexed without a signature, and primary-constructor + * properties are not indexed at all, so the Java-shaped field inference found + * no type and every `prop.method()` fell through to name-only guessing: the + * interface method, or any same-named method elsewhere (a `close()` on an + * unrelated class). The declared type is now read from the declaration. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import CodeGraph from '../src/index'; + +describe('Kotlin property receivers resolve on the declared type', () => { + let dir: string; + let cg: CodeGraph; + + beforeAll(async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-kt-prop-')); + const src = path.join(dir, 'src'); + fs.mkdirSync(src); + fs.writeFileSync(path.join(src, 'Probe.kt'), `package p +import java.io.Closeable +internal interface Probe : Closeable { + fun probe(host: String): Int +} +internal class HttpProbe( + private val endpoints: (String) -> List, +) : Probe { + override fun probe(host: String): Int = 1 + override fun close() {} +} +internal class OtherProbe : Closeable { + fun probe(host: String): Int = 2 + override fun close() {} +} +class Store { fun put(v: Int) = v } +class HardwareLock { + class Lease { + fun close() {} + } +} +class SessionLock { + class Lease { + fun close() {} + } +} +`); + fs.writeFileSync(path.join(src, 'Users.kt'), `package p +class LateinitUser { + private lateinit var probe: HttpProbe + fun setUp() { probe = HttpProbe(endpoints = { listOf(it) }) } + fun useIt() { probe.probe("a") } + fun tearDown() { probe.close() } +} +class CtorUser(private val store: Store, val maybe: HttpProbe?) { + fun save() { store.put(1) } + fun check() { maybe?.probe("b") } +} +class InitUser { + private val probe = HttpProbe(endpoints = { emptyList() }) + fun stop() { probe.close() } +} +class NestedUser(private val lease: HardwareLock.Lease) { + fun release() { lease.close() } +} +`); + cg = CodeGraph.initSync(dir, { config: { include: ['**/*.kt'], exclude: [] } }); + await cg.indexAll(); + }); + + afterAll(() => { + cg?.destroy(); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + /** Qualified names of what `owner.method` calls. */ + function callees(owner: string, method: string): string[] { + const node = cg.searchNodes(method).map((r) => r.node) + .find((n) => n.qualifiedName.endsWith(`${owner}::${method}`)); + expect(node, `${owner}::${method}`).toBeDefined(); + return cg.getCallees(node!.id).filter((c) => c.edge.kind === 'calls').map((c) => c.node.qualifiedName); + } + + it('a lateinit property resolves on its concrete type, not the interface', () => { + expect(callees('LateinitUser', 'useIt')).toEqual(['p::HttpProbe::probe']); + expect(callees('LateinitUser', 'tearDown')).toEqual(['p::HttpProbe::close']); + }); + + it('a primary-constructor property resolves on its type, nullable included', () => { + expect(callees('CtorUser', 'save')).toEqual(['p::Store::put']); + expect(callees('CtorUser', 'check')).toEqual(['p::HttpProbe::probe']); + }); + + it('a property initialized by a constructor call resolves on that type', () => { + expect(callees('InitUser', 'stop')).toEqual(['p::HttpProbe::close']); + }); + + it('a nested type keeps its outer type', () => { + expect(callees('NestedUser', 'release')).toEqual(['p::HardwareLock::Lease::close']); + }); +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index e0b4ad28b8..39a418ab92 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -6486,6 +6486,12 @@ function inferJavaFieldReceiverType( n.startLine >= enclosing.startLine && (n.endLine ?? n.startLine) <= enclosingEnd, ); + // Kotlin keeps no signature on a property, and a primary-constructor + // property (`class A(private val repo: Repo)`) is no node at all, so the + // declared type has to be read from the declaration itself. + if (ref.language === 'kotlin') { + return inferKotlinPropertyType(receiverName, field ?? null, enclosing, inFile, ref, context); + } if (!field || !field.signature) return null; // Signature shape: " " (extractField). Pull the type, @@ -6506,6 +6512,59 @@ function inferJavaFieldReceiverType( return lastPart; } +/** + * The declared type of a Kotlin property named `name` in class `owner`: from + * the property's own declaration lines when it is a node (`lateinit var repo: + * Repo`, `val repo = Repo(…)`), otherwise from the class header, where a + * primary-constructor property (`class A(private val repo: Repo)`) lives. The + * header is the class's lines before its first member, so a same-named local + * inside a method body is never read as the property. Returns null when no + * declaration names a type; resolveMethodOnType still validates the method. + */ +function inferKotlinPropertyType( + name: string, + property: Node | null, + owner: Node, + inFile: Node[], + ref: UnresolvedRef, + context: ResolutionContext, +): string | null { + const lines = context.getFileLines + ? context.getFileLines(ref.filePath) + : (context.readFile(ref.filePath)?.split(/\r?\n/) ?? null); + if (!lines || lines.length === 0) return null; + + const r = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const typed = new RegExp(`\\b(?:val|var)\\s+${r}\\s*:\\s*([A-Z][\\w.]*)`); + const constructed = new RegExp(`\\b(?:val|var)\\s+${r}\\s*=\\s*([A-Z][\\w.]*)\\s*\\(`); + const readType = (from: number, to: number): string | null => { + const text = lines.slice(Math.max(0, from - 1), Math.min(lines.length, to)).join('\n'); + const m = text.match(typed) ?? text.match(constructed); + if (!m || !m[1]) return null; + // A nested type (`HardwareLock.Lease`) keeps its outer type, joined the + // way qualified names are, so resolveMethodOnType matches that `Lease` + // and not another class's; a package prefix (lowercase) is dropped. + const segments = m[1].split('.'); + let first = segments.length; + while (first > 0 && /^[A-Z]/.test(segments[first - 1]!)) first--; + const typeSegments = segments.slice(first); + if (typeSegments.length > 1) return typeSegments.join('::'); + return normalizeInferredTypeName(m[1]); + }; + + if (property) return readType(property.startLine, property.endLine ?? property.startLine); + + const ownerEnd = owner.endLine ?? owner.startLine; + let firstMember = ownerEnd + 1; + for (const n of inFile) { + if (n.id === owner.id || n.kind === 'file') continue; + if (n.startLine > owner.startLine && n.startLine <= ownerEnd && n.startLine < firstMember) { + firstMember = n.startLine; + } + } + return readType(owner.startLine, firstMember - 1); +} + // ── Local-variable receiver-type inference (#1108) ────────────────────────── // // Instance calls through a local variable (`const lg = new Logger(); lg.log()`) From aaa47dd25ea5b95d1389c6d55a20a7f5c16bd8b2 Mon Sep 17 00:00:00 2001 From: danusha2345 Date: Thu, 24 Sep 2026 12:14:18 +0300 Subject: [PATCH 02/15] fix(kotlin): parse `fun interface` instead of losing the next declaration tree-sitter-kotlin has no `fun interface` (Kotlin 1.4 functional interfaces). The declaration parsed as a broken function, and when a doc comment followed it, error recovery swallowed the NEXT declaration too: an interface and its methods went missing, surfacing as top-level functions, or a class vanished with its members. The Kotlin extractor's preParse now blanks the `fun` of a `fun interface` declaration (three spaces for three letters, so offsets hold); the kernel receives the same bytes through the preParse hoist and no longer defers these files. Co-Authored-By: Claude Opus 5.5 --- __tests__/kernel-kotlin-parity.test.ts | 19 +++++++++++-------- docs/design/kotlin-kernel-port-checklist.md | 6 ++++++ src/extraction/languages/kotlin.ts | 20 +++++++++++++++++++- 3 files changed, 36 insertions(+), 9 deletions(-) diff --git a/__tests__/kernel-kotlin-parity.test.ts b/__tests__/kernel-kotlin-parity.test.ts index 649c444553..e6b5893221 100644 --- a/__tests__/kernel-kotlin-parity.test.ts +++ b/__tests__/kernel-kotlin-parity.test.ts @@ -117,16 +117,19 @@ describe.skipIf(!kernelBuilt)('kernel Kotlin extraction parity', () => { }); } - it('fun-interface files defer to the wasm extractor (grammar-inherent error)', () => { - const src = 'package p\n\nfun interface Transformer {\n fun transform(x: Int): Int\n}\n\nfun after() { work() }\n'; + it('fun-interface files parse on both paths once `fun` is blanked (preParse)', () => { + // tree-sitter-kotlin has no `fun interface`; the extractor's preParse + // blanks the `fun` (offset-preserving), so the kernel no longer defers + // and the declaration after it — here behind a doc comment, the shape + // whose error recovery used to swallow it — is extracted too. + const src = 'package p\n\nfun interface Transformer {\n fun transform(x: Int): Int\n}\n\n/** Next. */\ninterface Sink {\n fun put(x: Int)\n}\n\nfun after() { work() }\n'; + assertParity('src/FunIface.kt', src, 4); process.env.CODEGRAPH_KERNEL_LANGS = 'all'; delete process.env.CODEGRAPH_KERNEL; - expect(tryKernelExtract('src/FunIface.kt', src, 'kotlin')).toBeNull(); - process.env.CODEGRAPH_KERNEL = '0'; - const viaWasm = extractFromSource('src/FunIface.kt', src, 'kotlin'); - delete process.env.CODEGRAPH_KERNEL; - // The wasm arm's misparse-recovery hook still mints the interface node. - expect(viaWasm.nodes.some((n) => n.kind === 'interface' && n.name === 'Transformer')).toBe(true); + const result = tryKernelExtract('src/FunIface.kt', src, 'kotlin')!; + expect(result.nodes.some((n) => n.kind === 'interface' && n.name === 'Transformer')).toBe(true); + expect(result.nodes.some((n) => n.kind === 'interface' && n.name === 'Sink')).toBe(true); + expect(result.nodes.some((n) => n.kind === 'method' && n.qualifiedName.endsWith('Sink::put'))).toBe(true); }); it('PHANTOM errors defer too — hasError with a complete, ERROR-node-free CST', () => { diff --git a/docs/design/kotlin-kernel-port-checklist.md b/docs/design/kotlin-kernel-port-checklist.md index fcbdcd9c8a..d895d8ba4a 100644 --- a/docs/design/kotlin-kernel-port-checklist.md +++ b/docs/design/kotlin-kernel-port-checklist.md @@ -299,6 +299,12 @@ Hooks PRESENT (port each exactly): DEFER-SHIELDED in the kernel**: every `fun interface` (either pattern, probed) makes the tree `hasError=true`, so the kernel defers the whole FILE to wasm before the walker would run. **Do NOT port branches 2-3.** + *(2026-09-24: superseded for `fun interface` — the extractor's + `preParse` now blanks the `fun` of a `fun interface` declaration, + offset-preserving, so such a file parses cleanly and the kernel extracts + it; the wasm recovery branches no longer fire for it. The old defer also + lost the declaration AFTER the `fun interface` when a doc comment sat + between them.)* Walker rule: port branch 1 only; a `defer:` on has_error covers the rest. (The parity suite still needs a fun-interface fixture asserting the kernel defers and the wasm arm serves the pinned output.) diff --git a/src/extraction/languages/kotlin.ts b/src/extraction/languages/kotlin.ts index c41b2cd38b..d5c7f4bae0 100644 --- a/src/extraction/languages/kotlin.ts +++ b/src/extraction/languages/kotlin.ts @@ -161,6 +161,24 @@ function isFunInterfaceNode(node: SyntaxNode): boolean { return hasFun && hasInterfaceType; } +/** + * tree-sitter-kotlin predates functional interfaces (Kotlin 1.4): `fun + * interface Reply { … }` parses as a broken function declaration, and its + * error recovery swallows the NEXT declaration as well, so that interface and + * its methods go missing and its members surface as top-level functions. + * Blanking the `fun` of a `fun interface` declaration (three spaces for three + * letters, so every offset holds) lets it parse as the plain interface it is. + * Only a declaration position is touched: the start of a line, after optional + * visibility / multiplatform modifiers. + */ +export function blankKotlinFunInterface(source: string): string { + if (!source.includes('interface')) return source; + return source.replace( + /^([ \t]*(?:(?:public|private|internal|protected|expect|actual)[ \t]+)*)fun([ \t]+interface\b)/gm, + (_match, before: string, after: string) => `${before} ${after}`, + ); +} + /** * The qualifier of a function type's receiver — `DatabaseConfig.` in * `configure: (DatabaseConfig.Builder.() -> Unit)?`. The grammar reads a @@ -225,7 +243,7 @@ export function rewriteNewerKotlinSyntax(source: string): string { } function preParseKotlin(source: string): string { - return rewriteNewerKotlinSyntax(joinKotlinSplitConstructors(blankKotlinQualifiedReceivers(source))); + return rewriteNewerKotlinSyntax(joinKotlinSplitConstructors(blankKotlinQualifiedReceivers(blankKotlinFunInterface(source)))); } export const kotlinExtractor: LanguageExtractor = { From 9910fd7843e815cd3ff4c668a9d795c3cb3967f4 Mon Sep 17 00:00:00 2001 From: danusha2345 Date: Thu, 24 Sep 2026 12:14:18 +0300 Subject: [PATCH 03/15] fix(kotlin): type receivers bound to calls, and leave library types unguessed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more receivers the Kotlin resolver could not type: - A local or property bound to a call (`val lease = HardwareLock.tryBegin()`, `private val schema = Checker.load()`) is typed by the callee's declared return type, from its signature. A return type nested in the callee's owner keeps it (`Lease` in `HardwareLock` → `HardwareLock::Lease`). - A receiver whose type the project does not declare (`Regex`, `Properties`, a call on `Executors`) now gets no edge. The name-only fallback used to bind it to any project method of the same name: a `regex.find()` to an unrelated `find`, `socket.connect()` to a `connect` elsewhere, a `close()` to itself. Co-Authored-By: Claude Opus 5.5 --- __tests__/kotlin-property-receiver.test.ts | 56 ++++++ src/resolution/name-matcher.ts | 188 +++++++++++++++++++-- 2 files changed, 227 insertions(+), 17 deletions(-) diff --git a/__tests__/kotlin-property-receiver.test.ts b/__tests__/kotlin-property-receiver.test.ts index 5ebd2bc645..ba36fbcc66 100644 --- a/__tests__/kotlin-property-receiver.test.ts +++ b/__tests__/kotlin-property-receiver.test.ts @@ -47,6 +47,25 @@ class SessionLock { fun close() {} } } +class AAValidator { fun validate(v: String): List = emptyList() } +object HardwareGate { + fun tryBegin(): HardwareLock.Lease? = null +} +class Checker { + fun validate(v: String): List = emptyList() + companion object { + fun load(): Checker = Checker() + } +} +internal fun interface Reply { + fun send(message: String): Boolean +} + +/** Events of one session, for the application layer. */ +internal interface SessionEvents { + fun onLost(reason: String) +} +class Pattern { fun find(text: String): Int = 0 } `); fs.writeFileSync(path.join(src, 'Users.kt'), `package p class LateinitUser { @@ -66,6 +85,25 @@ class InitUser { class NestedUser(private val lease: HardwareLock.Lease) { fun release() { lease.close() } } +class CallResultUser { + private val schema = Checker.load() + fun check() { schema.validate("x") } + fun locked() { + val lease = HardwareGate.tryBegin() + lease?.close() + } +} +class Supervisor(private val events: SessionEvents) { + fun lost() { events.onLost("gone") } +} +class LibraryUser { + private val regex = Regex("[0-9]+") + fun first(text: String) = regex.find(text) + fun scheduled() { + val scheduler = java.util.concurrent.Executors.newSingleThreadScheduledExecutor() + scheduler.shutdown() + } +} `); cg = CodeGraph.initSync(dir, { config: { include: ['**/*.kt'], exclude: [] } }); await cg.indexAll(); @@ -98,6 +136,24 @@ class NestedUser(private val lease: HardwareLock.Lease) { expect(callees('InitUser', 'stop')).toEqual(['p::HttpProbe::close']); }); + it('a local or property bound to a call is typed by the callee return type', () => { + expect(callees('CallResultUser', 'check')).toEqual(['p::Checker::validate']); + expect(callees('CallResultUser', 'locked').sort()).toEqual(['p::HardwareGate::tryBegin', 'p::HardwareLock::Lease::close']); + }); + + it('a fun interface does not swallow the declaration after it', () => { + const events = cg.searchNodes('SessionEvents').map((r) => r.node) + .find((n) => n.qualifiedName === 'p::SessionEvents'); + expect(events?.kind).toBe('interface'); + expect(callees('Supervisor', 'lost')).toEqual(['p::SessionEvents::onLost']); + }); + + it('a receiver of a library type gets no edge instead of a same-named project method', () => { + // `Regex.find` must not bind to the project's `Pattern.find`. + expect(callees('LibraryUser', 'first')).toEqual([]); + expect(callees('LibraryUser', 'scheduled')).toEqual([]); + }); + it('a nested type keeps its outer type', () => { expect(callees('NestedUser', 'release')).toEqual(['p::HardwareLock::Lease::close']); }); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 39a418ab92..811c09eb27 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -6534,23 +6534,9 @@ function inferKotlinPropertyType( : (context.readFile(ref.filePath)?.split(/\r?\n/) ?? null); if (!lines || lines.length === 0) return null; - const r = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const typed = new RegExp(`\\b(?:val|var)\\s+${r}\\s*:\\s*([A-Z][\\w.]*)`); - const constructed = new RegExp(`\\b(?:val|var)\\s+${r}\\s*=\\s*([A-Z][\\w.]*)\\s*\\(`); - const readType = (from: number, to: number): string | null => { - const text = lines.slice(Math.max(0, from - 1), Math.min(lines.length, to)).join('\n'); - const m = text.match(typed) ?? text.match(constructed); - if (!m || !m[1]) return null; - // A nested type (`HardwareLock.Lease`) keeps its outer type, joined the - // way qualified names are, so resolveMethodOnType matches that `Lease` - // and not another class's; a package prefix (lowercase) is dropped. - const segments = m[1].split('.'); - let first = segments.length; - while (first > 0 && /^[A-Z]/.test(segments[first - 1]!)) first--; - const typeSegments = segments.slice(first); - if (typeSegments.length > 1) return typeSegments.join('::'); - return normalizeInferredTypeName(m[1]); - }; + const readType = (from: number, to: number): string | null => + kotlinDeclaredTypeIn( + lines.slice(Math.max(0, from - 1), Math.min(lines.length, to)).join('\n'), name, ref, context); if (property) return readType(property.startLine, property.endLine ?? property.startLine); @@ -6565,6 +6551,154 @@ function inferKotlinPropertyType( return readType(owner.startLine, firstMember - 1); } + +/** Node kinds that declare a type a receiver can have. */ +const DECLARED_TYPE_KINDS = new Set([ + 'class', 'interface', 'struct', 'enum', 'type_alias', 'trait', 'protocol', 'union', +]); + +/** + * Whether the project declares `typeName` (simple, or `Outer::Inner`) in the + * ref's language family. False for library types (`Regex`, `Properties`). + */ +function isProjectType(typeName: string, ref: UnresolvedRef, context: ResolutionContext): boolean { + const simple = typeName.split('::').pop()!; + return context.getNodesByName(simple).some( + (n) => + DECLARED_TYPE_KINDS.has(n.kind) && + sameLanguageFamily(n.language, ref.language) && + (!typeName.includes('::') || n.qualifiedName === typeName || n.qualifiedName.endsWith(`::${typeName}`)), + ); +} + +/** + * A Kotlin type written in source (`Lease?`, `HardwareLock.Lease`, + * `List`) as a name resolveMethodOnType matches: nullability and type + * arguments dropped, a nested type joined with `::`, a package prefix + * (lowercase segments) dropped. Null when it is not a type name. + */ +function kotlinTypeName(raw: string): string | null { + const cleaned = raw.replace(/<[^>]*>/g, '').replace(/\?/g, '').trim(); + const segments = cleaned.split('.').filter(Boolean); + let first = segments.length; + while (first > 0 && /^[A-Z]\w*$/.test(segments[first - 1]!)) first--; + const typeSegments = segments.slice(first); + return typeSegments.length > 0 ? typeSegments.join('::') : null; +} + +/** + * Stands for "a type the project does not declare" — a library class, or the + * result of a call on one — so the caller can refuse a name-only guess. + */ +export const KOTLIN_EXTERNAL_TYPE = '\u0000external'; + +/** + * The type a Kotlin declaration of `name` in `text` gives it: `name: Type`, a + * constructor call `name = Type(…)`, or a call `name = Owner.fn(…)` / + * `name = fn(…)`, typed by `fn`'s declared return type. KOTLIN_EXTERNAL_TYPE + * when the owner is not a project type; null when nothing names a type. + */ +function kotlinDeclaredTypeIn( + text: string, + name: string, + ref: UnresolvedRef, + context: ResolutionContext, +): string | null { + const r = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const typed = text.match(new RegExp(`\\b(?:val|var)\\s+${r}\\s*:\\s*([A-Z][\\w.]*)`)); + if (typed && typed[1]) return kotlinTypeName(typed[1]); + const assigned = text.match(new RegExp(`\\b(?:val|var)\\s+${r}\\s*=\\s*([A-Za-z_][\\w.]*)\\s*\\(`)); + if (!assigned || !assigned[1]) return null; + const segments = assigned[1].split('.'); + const last = segments.pop()!; + if (/^[A-Z]/.test(last)) return kotlinTypeName(assigned[1]); + // A call. Only an owner written as a type chain (`Lock.tryBegin`) or none at + // all (`beginOp()`) can be typed; `a.b.fn()` goes through values. + if (segments.length > 0 && !segments.every((seg) => /^[A-Z]/.test(seg))) return null; + return kotlinCallResultType(segments.length > 0 ? segments.join('::') : undefined, last, ref, context); +} + +/** + * The declared return type of Kotlin `owner.fn` (or of an unqualified `fn`, a + * method of the ref's own class first, then one in the same file). Every + * candidate must declare the same return type. A return type nested in the + * callee's owner keeps that owner (`Lease` in `HardwareLock` → + * `HardwareLock::Lease`). KOTLIN_EXTERNAL_TYPE for a call on a type the + * project does not declare (`Executors.newSingleThreadScheduledExecutor()`). + */ +function kotlinCallResultType( + owner: string | undefined, + fn: string, + ref: UnresolvedRef, + context: ResolutionContext, +): string | null { + if (owner && !isProjectType(owner, ref, context)) return KOTLIN_EXTERNAL_TYPE; + let candidates = context.getNodesByName(fn).filter( + (n) => (n.kind === 'method' || n.kind === 'function') && n.language === 'kotlin' && !!n.signature, + ); + if (owner) { + candidates = candidates.filter((n) => n.qualifiedName.endsWith(`${owner}::${fn}`)); + } else { + const ownClass = context.getNodesInFile(ref.filePath) + .filter((n) => (n.kind === 'class' || n.kind === 'interface') && n.startLine <= ref.line && (n.endLine ?? n.startLine) >= ref.line) + .sort((a, b) => b.startLine - a.startLine)[0]; + const inOwnClass = ownClass + ? candidates.filter((n) => n.qualifiedName === `${ownClass.qualifiedName}::${fn}`) + : []; + const inFile = candidates.filter((n) => n.filePath === ref.filePath); + candidates = inOwnClass.length > 0 ? inOwnClass : inFile.length > 0 ? inFile : candidates; + } + if (candidates.length === 0) return null; + + let result: string | null = null; + for (const c of candidates) { + const sig = c.signature!; + const at = sig.lastIndexOf('):'); + if (at < 0) return null; + let type = kotlinTypeName(sig.slice(at + 2)); + if (!type) return null; + if (!type.includes('::')) { + const container = c.qualifiedName.slice(0, c.qualifiedName.lastIndexOf('::')); + const containerName = container.split('::').pop(); + if (containerName && context.getNodesByQualifiedName(`${container}::${type}`).some((n) => DECLARED_TYPE_KINDS.has(n.kind))) { + type = `${containerName}::${type}`; + } + } + if (result !== null && result !== type) return null; + result = type; + } + return result; +} + +/** + * The declared type of Kotlin receiver `name` at `ref`: the nearest local + * declaration in the enclosing scope (typed, constructed, or bound to a call), + * else a property of the enclosing class. Null when none names a type. + */ +function kotlinReceiverDeclaredType( + name: string, + ref: UnresolvedRef, + context: ResolutionContext, +): string | null { + const lines = context.getFileLines + ? context.getFileLines(ref.filePath) + : (context.readFile(ref.filePath)?.split(/\r?\n/) ?? null); + if (lines && lines.length > 0) { + const r = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const declares = new RegExp(`\\b(?:val|var)\\s+${r}\\b`); + const callIdx = Math.max(0, Math.min(lines.length - 1, ref.line - 1)); + const startIdx = Math.max(0, enclosingScopeStartLine(ref, context) - 1); + for (let i = callIdx; i >= startIdx; i--) { + const line = lines[i]; + if (!line || line.length > 10_000 || !declares.test(line)) continue; + const local = kotlinDeclaredTypeIn(line, name, ref, context); + if (local) return local; + break; + } + } + return inferJavaFieldReceiverType(name, ref, context); +} + // ── Local-variable receiver-type inference (#1108) ────────────────────────── // // Instance calls through a local variable (`const lg = new Logger(); lg.log()`) @@ -7936,6 +8070,26 @@ export function matchMethodCall( } } + // Kotlin: a receiver whose type is known — a local bound to a call + // (`val lease = HardwareLock.tryBegin()`, typed by the callee's declared + // return type), a local constructor call, or a property — resolves on that + // type. A type the project does not declare (`Regex`, `Properties`, a JDK or + // Android class) has none of the project's methods, so the call gets NO + // edge instead of the name-only guesses below, which bind it to whatever + // project class declares a method of the same name. + if (ref.language === 'kotlin' && dotMatch && !objectOrClass!.includes('.')) { + const declared = nmTimedT('mc-kt-declared', ref, () => + kotlinReceiverDeclaredType(objectOrClass!, ref, context)); + if (declared === KOTLIN_EXTERNAL_TYPE) return null; + if (declared) { + const typedMatch = nmTimedT('mc-rmot', ref, () => resolveMethodOnType( + declared, methodName!, ref, context, 0.9, 'instance-method', + )); + if (typedMatch) return typedMatch; + if (!isProjectType(declared, ref, context)) return null; + } + } + // Object-literal namespace receiver (#1573): `api.call()` where `api` is a // same-file `const api = { call() {…}, get: () => {…} }`. Its members are // plain functions with bare names inside the constant's extent — no From 61b33f288ab098255b32f83e0b3c4f63af718818 Mon Sep 17 00:00:00 2001 From: danusha2345 Date: Thu, 24 Sep 2026 12:14:40 +0300 Subject: [PATCH 04/15] docs(changelog): Kotlin fun interface and call-typed receivers Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 14a941988d..5b07e05aeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -262,7 +262,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - The Claude Code prompt hook no longer runs on the task-notification messages Claude Code injects when a background agent finishes, removing a multi-second stall on every such turn. (#1832) -- Kotlin calls through a class property, including a primary-constructor property, now resolve on the property's declared type instead of a same-named method on the interface or on an unrelated class. Re-index Kotlin projects to pick this up. +- Kotlin calls through a class property, a primary-constructor property, or a variable set from a function call now resolve on the declared type, and a call on a library type (such as `Regex` or a JDK class) no longer links to an unrelated project method with the same name. Re-index Kotlin projects to pick this up. +- Kotlin `fun interface` declarations are indexed, and no longer hide the declaration that follows them. - Rust calls on `self` now stay with the enclosing type instead of linking to an unrelated type’s same-named method. Thanks @L4XB. (#1861) From d1f2144800b9ec3c2939f197b0acc0f4897f771f Mon Sep 17 00:00:00 2001 From: danusha2345 Date: Thu, 24 Sep 2026 12:28:50 +0300 Subject: [PATCH 05/15] fix(kotlin): type val/companion properties, aliases, lambda params, trailing-lambda constructors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit More Kotlin receivers the resolver could not type, found on real apps: - A `val` property is indexed as a `constant` and a companion-object property as a `variable`; the property lookup only took `field`. - `var mode = Mode.ON` is typed by its enum (project enums only; `Limits.MAX` is an Int). - `val t = Thread { … }` is a constructor too (trailing lambda, no parens). - `val old = current ?: return` / `current!!` / `val x = current` take the aliased value's type; `requireNotNull(x)` / `checkNotNull(x)` take x's. - A typed parameter of the enclosing function or overridden callback (`onOpen(webSocket: WebSocket, …)`) is used when nothing else names one. - Known stdlib factories (`listOf`, `mutableMapOf`, `lazy`, `thread`, …) return a library type; any other library function leaves the type unknown. A library type still gets no edge: `thread.join()`, `process.destroy()`, `webSocket.send()` no longer bind to project methods (or a test fake) of the same name. Co-Authored-By: Claude Opus 5.5 --- __tests__/kotlin-property-receiver.test.ts | 75 ++++++++++++++++++++++ src/resolution/name-matcher.ts | 61 ++++++++++++++++-- 2 files changed, 130 insertions(+), 6 deletions(-) diff --git a/__tests__/kotlin-property-receiver.test.ts b/__tests__/kotlin-property-receiver.test.ts index ba36fbcc66..937e07da92 100644 --- a/__tests__/kotlin-property-receiver.test.ts +++ b/__tests__/kotlin-property-receiver.test.ts @@ -66,6 +66,21 @@ internal interface SessionEvents { fun onLost(reason: String) } class Pattern { fun find(text: String): Int = 0 } +class Conn { fun close() {} } +class Buffer(val size: Int) { fun drain(): Int = 0 } +class Pump { fun drain(): Int = 1 } +enum class Mode { ON, OFF; fun next(): Mode = this } +class Wheel { fun next(): Int = 0 } +class Worker { fun start() {}; fun join(ms: Long) {} } +class Vm { + fun refresh() {} + fun tick() { + activeController?.refresh() + } + companion object { + @Volatile private var activeController: Vm? = null + } +} `); fs.writeFileSync(path.join(src, 'Users.kt'), `package p class LateinitUser { @@ -96,6 +111,44 @@ class CallResultUser { class Supervisor(private val events: SessionEvents) { fun lost() { events.onLost("gone") } } +class ValUser { + private val pending = Buffer( + size = 4, + ) + @Volatile private var current = Mode.ON + fun work() { + listOf(1).forEach { + pending.drain() + } + } + fun toggle() { current.next() } +} +class Socket2 { fun cancel() {} } +class Session { fun cancel() {} } +abstract class Listener { abstract fun onOpen(socket: java.net.Socket) } +class AliasUser { + @Volatile private var current: Session? = null + fun stop() { + val old = current ?: return + old.cancel() + } + fun listen(): Listener = object : Listener() { + override fun onOpen(socket: java.net.Socket) { + socket.close() + } + } +} +class ThreadUser { + fun run() { + val serverThread = Thread { + println("x") + } + serverThread.start() + val later = lazy { 1 } + val items = mutableListOf(1) + items.clear() + } +} class LibraryUser { private val regex = Regex("[0-9]+") fun first(text: String) = regex.find(text) @@ -154,6 +207,28 @@ class LibraryUser { expect(callees('LibraryUser', 'scheduled')).toEqual([]); }); + it('a val property (indexed as a constant) and an enum-entry var are typed, inside a lambda too', () => { + expect(callees('ValUser', 'work')).toContain('p::Buffer::drain'); + expect(callees('ValUser', 'work')).not.toContain('p::Pump::drain'); + expect(callees('ValUser', 'toggle')).toEqual(['p::Mode::next']); + }); + + it('a companion-object property is typed', () => { + expect(callees('Vm', 'tick')).toEqual(['p::Vm::refresh']); + }); + + it('a trailing-lambda constructor or a library call types a local as a library type', () => { + // `Thread { … }.start()` must not bind to the project's `Worker.start`. + expect(callees('ThreadUser', 'run')).toEqual([]); + }); + + it('an alias takes the aliased value type; a library-typed parameter gets no edge', () => { + expect(callees('AliasUser', 'stop')).toEqual(['p::Session::cancel']); + const onOpen = cg.searchNodes('onOpen').map((r) => r.node).find((n) => n.filePath.endsWith('Users.kt')); + expect(onOpen).toBeDefined(); + expect(cg.getCallees(onOpen!.id).filter((c) => c.edge.kind === 'calls')).toEqual([]); + }); + it('a nested type keeps its outer type', () => { expect(callees('NestedUser', 'release')).toEqual(['p::HardwareLock::Lease::close']); }); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 811c09eb27..6694cd3835 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -6478,9 +6478,11 @@ function inferJavaFieldReceiverType( if (!enclosing) return null; const enclosingEnd = enclosing.endLine ?? enclosing.startLine; + // Kotlin indexes a `val` property as a `constant`, a `var` as a `field`, and + // a companion-object property as a `variable`. const field = inFile.find( (n) => - n.kind === 'field' && + (n.kind === 'field' || (ref.language === 'kotlin' && (n.kind === 'constant' || n.kind === 'variable'))) && n.name === receiverName && n.language === ref.language && n.startLine >= enclosing.startLine && @@ -6603,21 +6605,59 @@ function kotlinDeclaredTypeIn( name: string, ref: UnresolvedRef, context: ResolutionContext, + depth = 0, ): string | null { const r = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const typed = text.match(new RegExp(`\\b(?:val|var)\\s+${r}\\s*:\\s*([A-Z][\\w.]*)`)); if (typed && typed[1]) return kotlinTypeName(typed[1]); - const assigned = text.match(new RegExp(`\\b(?:val|var)\\s+${r}\\s*=\\s*([A-Za-z_][\\w.]*)\\s*\\(`)); - if (!assigned || !assigned[1]) return null; + // An alias of another value: `val old = ws`, `val s = current ?: return`, + // `val s = current!!` — typed as that value. + const alias = text.match(new RegExp(`\\b(?:val|var)\\s+${r}\\s*=\\s*([a-z_]\\w*)\\s*(?:!!|\\?:\\s*(?:return|continue|break|throw)\\b[^\\n]*)?\\s*$`, 'm')); + if (alias && alias[1] && alias[1] !== name && depth < 3) { + return kotlinReceiverDeclaredType(alias[1], ref, context, depth + 1); + } + // `(` or a trailing lambda `{`: `Thread { … }`, `thread { … }`. + const assigned = text.match(new RegExp(`\\b(?:val|var)\\s+${r}\\s*=\\s*([A-Za-z_][\\w.]*)\\s*[({]`)); + if (!assigned || !assigned[1]) { + // An enum entry or other constant of a type: `var mode = GpsMode.ON`. + const entry = text.match(new RegExp(`\\b(?:val|var)\\s+${r}\\s*=\\s*([A-Z]\\w*(?:\\.[A-Z]\\w*)*)\\.[A-Z][A-Z0-9_]*\\b(?!\\s*[.(])`)); + if (!entry || !entry[1]) return null; + // Only an entry of a project enum names its type; `Limits.MAX` is an Int. + const type = kotlinTypeName(entry[1]); + const simple = type?.split('::').pop(); + return simple && context.getNodesByName(simple).some( + (n) => n.kind === 'enum' && sameLanguageFamily(n.language, ref.language), + ) ? type : null; + } const segments = assigned[1].split('.'); const last = segments.pop()!; if (/^[A-Z]/.test(last)) return kotlinTypeName(assigned[1]); // A call. Only an owner written as a type chain (`Lock.tryBegin`) or none at // all (`beginOp()`) can be typed; `a.b.fn()` goes through values. if (segments.length > 0 && !segments.every((seg) => /^[A-Z]/.test(seg))) return null; + if (segments.length === 0 && KOTLIN_ARGUMENT_PASSTHROUGH.has(last)) { + const arg = text.match(new RegExp(`\\b${last}\\s*\\(\\s*([A-Za-z_]\\w*)\\s*[,)]`)); + if (!arg || !arg[1] || arg[1] === name) return null; + // The argument is a parameter or local of the same scope. + const argType = inferLocalReceiverType(arg[1], ref, context); + return argType && /^[A-Z]/.test(argType) ? argType : null; + } return kotlinCallResultType(segments.length > 0 ? segments.join('::') : undefined, last, ref, context); } +/** Kotlin stdlib functions whose result is always a library type. */ +const KOTLIN_LIBRARY_FACTORIES = new Set([ + 'listOf', 'mutableListOf', 'arrayListOf', 'emptyList', 'listOfNotNull', + 'setOf', 'mutableSetOf', 'hashSetOf', 'linkedSetOf', 'sortedSetOf', 'emptySet', + 'mapOf', 'mutableMapOf', 'hashMapOf', 'linkedMapOf', 'sortedMapOf', 'emptyMap', + 'arrayOf', 'arrayOfNulls', 'emptyArray', 'intArrayOf', 'longArrayOf', 'byteArrayOf', + 'sequenceOf', 'emptySequence', 'buildList', 'buildSet', 'buildMap', 'buildString', + 'lazy', 'thread', 'mutableStateOf', 'Channel', 'MutableStateFlow', 'MutableSharedFlow', +]); + +/** Stdlib functions that return their (non-null) argument: `requireNotNull(x)`. */ +const KOTLIN_ARGUMENT_PASSTHROUGH = new Set(['requireNotNull', 'checkNotNull']); + /** * The declared return type of Kotlin `owner.fn` (or of an unqualified `fn`, a * method of the ref's own class first, then one in the same file). Every @@ -6648,7 +6688,10 @@ function kotlinCallResultType( const inFile = candidates.filter((n) => n.filePath === ref.filePath); candidates = inOwnClass.length > 0 ? inOwnClass : inFile.length > 0 ? inFile : candidates; } - if (candidates.length === 0) return null; + // No project function of that name. A known stdlib factory returns a + // library type; any other library function (`requireNotNull`, `let`) may + // return a project value, so its result stays unknown. + if (candidates.length === 0) return !owner && KOTLIN_LIBRARY_FACTORIES.has(fn) ? KOTLIN_EXTERNAL_TYPE : null; let result: string | null = null; for (const c of candidates) { @@ -6679,6 +6722,7 @@ function kotlinReceiverDeclaredType( name: string, ref: UnresolvedRef, context: ResolutionContext, + depth = 0, ): string | null { const lines = context.getFileLines ? context.getFileLines(ref.filePath) @@ -6691,12 +6735,17 @@ function kotlinReceiverDeclaredType( for (let i = callIdx; i >= startIdx; i--) { const line = lines[i]; if (!line || line.length > 10_000 || !declares.test(line)) continue; - const local = kotlinDeclaredTypeIn(line, name, ref, context); + const local = kotlinDeclaredTypeIn(line, name, ref, context, depth); if (local) return local; break; } } - return inferJavaFieldReceiverType(name, ref, context); + const property = inferJavaFieldReceiverType(name, ref, context); + if (property) return property; + // A typed parameter of the enclosing function or lambda (`webSocket: + // WebSocket` in an overridden callback). + const param = inferLocalReceiverType(name, ref, context); + return param && /^[A-Z]/.test(param) ? param : null; } // ── Local-variable receiver-type inference (#1108) ────────────────────────── From 1b85cd4e5b4febc8d8619e7d3627b7d30003bbec Mon Sep 17 00:00:00 2001 From: danusha2345 Date: Thu, 24 Sep 2026 15:46:07 +0300 Subject: [PATCH 06/15] fix(kotlin): type chained receivers through property declarations A Kotlin call through a receiver chain (`engine.pump.drain()`, `this.engine.drain()`, `a?.b?.c()`) was extracted as the bare method name, so it could only be name-guessed: a same-named method in the caller's file, or any project method when the chain ended in a library type (`runtime.reconnectTask?.cancel()` on a `ScheduledFuture`). The extractor (wasm path and kernel, in parity) now keeps a chain of up to four identifier segments. The resolver types the first segment as a single receiver (`this` is the enclosing class, a type name its object or companion), then each next segment as a property or enum entry declared in the class of the type before it, read from that class's own file. A typed chain resolves on its type; a chain through a library type gets no edge unless the project declares an extension of that name on a library type; an untyped chain resolves as the bare method name, exactly as before. A constructor call with type arguments (`Crate()`, `mutableListOf()`) now types its value too. The alias test now picks the anonymous object's `onOpen` and ignores synthesized override edges, so it holds once object-literal members are extracted. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- __tests__/fixtures/kernel-parity/torture.kt | 17 +++ __tests__/kotlin-property-receiver.test.ts | 60 ++++++++++- codegraph-kernel/src/kotlin.rs | 34 +++++- src/extraction/tree-sitter.ts | 17 +++ src/resolution/index.ts | 14 ++- src/resolution/name-matcher.ts | 112 +++++++++++++++++++- 7 files changed, 249 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b07e05aeb..3e30e4abea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -262,7 +262,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - The Claude Code prompt hook no longer runs on the task-notification messages Claude Code injects when a background agent finishes, removing a multi-second stall on every such turn. (#1832) -- Kotlin calls through a class property, a primary-constructor property, or a variable set from a function call now resolve on the declared type, and a call on a library type (such as `Regex` or a JDK class) no longer links to an unrelated project method with the same name. Re-index Kotlin projects to pick this up. +- Kotlin calls through a class property, a primary-constructor property, or a variable set from a function call now resolve on the declared type, and a call on a library type (such as `Regex` or a JDK class) no longer links to an unrelated project method with the same name. Chained receivers such as `engine.pump.drain()`, `this.engine.drain()` and `Mode.ON.next()` resolve the same way. Re-index Kotlin projects to pick this up. - Kotlin `fun interface` declarations are indexed, and no longer hide the declaration that follows them. - Rust calls on `self` now stay with the enclosing type instead of linking to an unrelated type’s same-named method. Thanks @L4XB. (#1861) diff --git a/__tests__/fixtures/kernel-parity/torture.kt b/__tests__/fixtures/kernel-parity/torture.kt index c5531c55e2..cfafd47741 100644 --- a/__tests__/fixtures/kernel-parity/torture.kt +++ b/__tests__/fixtures/kernel-parity/torture.kt @@ -289,3 +289,20 @@ class SiblingAccessorK { register(fromInit) } } + +class ReceiverChainK(private val engine: EngineK) { + fun chains() { + engine.pump.drain() + this.engine.drain() + engine?.pump?.drain() + engine + .pump + .drain() + engine.a.b.c.drain() + engine.a.b.c.d.drain() + engine!!.pump.drain() + (engine).pump.drain() + engine.pump().drain() + super.toString().length.toString() + } +} diff --git a/__tests__/kotlin-property-receiver.test.ts b/__tests__/kotlin-property-receiver.test.ts index 937e07da92..7089f1ceb3 100644 --- a/__tests__/kotlin-property-receiver.test.ts +++ b/__tests__/kotlin-property-receiver.test.ts @@ -157,6 +157,34 @@ class LibraryUser { scheduler.shutdown() } } +`); + fs.writeFileSync(path.join(src, 'Machines.kt'), `package p +class Tank { fun flush() {} } +class Motor { + val reservoir = Tank() + val runner = Thread { } +} +class Plant(val unit: Motor) +class Crate { fun seal() {} } +object Registry { val primary = Motor() } +enum class Gear { LOW, HIGH; fun shift() {} } +fun Thread.label(): String = name +`); + // The decoy `flush` sits in the caller's own file, where a name-only + // guess would look first. + fs.writeFileSync(path.join(src, 'Chains.kt'), `package p +class Other { fun flush() {}; fun seal() {}; fun shift() {} } +class Owner(private val engine: Motor, private val site: Plant) { + private val box = Crate() + fun generic() { box.seal() } + fun viaObject() { Registry.primary.reservoir.flush() } + fun viaEnum() { Gear.LOW.shift() } + fun go() { engine.reservoir.flush() } + fun viaThis() { this.engine.reservoir.flush() } + fun deep() { site.unit.reservoir.flush() } + fun library() { engine.runner.start() } + fun extension() { engine.runner.label() } +} `); cg = CodeGraph.initSync(dir, { config: { include: ['**/*.kt'], exclude: [] } }); await cg.indexAll(); @@ -224,9 +252,37 @@ class LibraryUser { it('an alias takes the aliased value type; a library-typed parameter gets no edge', () => { expect(callees('AliasUser', 'stop')).toEqual(['p::Session::cancel']); - const onOpen = cg.searchNodes('onOpen').map((r) => r.node).find((n) => n.filePath.endsWith('Users.kt')); + // The anonymous object's `onOpen`, not the abstract `Listener::onOpen` + // (which may carry a synthesized override edge to it). + const listen = cg.searchNodes('listen').map((r) => r.node) + .find((n) => n.qualifiedName.endsWith('AliasUser::listen')); + expect(listen).toBeDefined(); + const onOpens = cg.searchNodes('onOpen').map((r) => r.node).filter((n) => n.filePath.endsWith('Users.kt')); + const onOpen = onOpens.find((n) => n.qualifiedName.includes('$anon')) ?? + onOpens.find((n) => n.startLine > listen!.startLine && n.startLine <= (listen!.endLine ?? listen!.startLine)); expect(onOpen).toBeDefined(); - expect(cg.getCallees(onOpen!.id).filter((c) => c.edge.kind === 'calls')).toEqual([]); + const calls = cg.getCallees(onOpen!.id) + .filter((c) => c.edge.kind === 'calls' && !c.edge.metadata?.synthesizedBy && !c.edge.metadata?.registeredAt); + expect(calls).toEqual([]); + }); + + it('a receiver chain is typed through each property declared type', () => { + expect(callees('Owner', 'go')).toEqual(['p::Tank::flush']); + expect(callees('Owner', 'viaThis')).toEqual(['p::Tank::flush']); + expect(callees('Owner', 'deep')).toEqual(['p::Tank::flush']); + }); + + it('a chain may start at an object or an enum entry; a generic constructor types its property', () => { + expect(callees('Owner', 'viaObject')).toEqual(['p::Tank::flush']); + expect(callees('Owner', 'viaEnum')).toEqual(['p::Gear::shift']); + expect(callees('Owner', 'generic')).toEqual(['p::Crate::seal']); + }); + + it('a receiver chain through a library type gets no edge', () => { + // `Thread.start` must not bind to the project's `Worker.start`. + expect(callees('Owner', 'library')).toEqual([]); + // The project's own extension of that library type is still reached. + expect(callees('Owner', 'extension')).toEqual(['Thread::label']); }); it('a nested type keeps its outer type', () => { diff --git a/codegraph-kernel/src/kotlin.rs b/codegraph-kernel/src/kotlin.rs index a224cb7265..8526b098c4 100644 --- a/codegraph-kernel/src/kotlin.rs +++ b/codegraph-kernel/src/kotlin.rs @@ -87,6 +87,28 @@ fn strip_js_ws(s: &str) -> String { s.chars().filter(|c| !is_js_space(*c)).collect() } +/// A receiver chain the resolver can type (TS `KOTLIN_RECEIVER_CHAIN`): +/// whitespace stripped, `?.` read as `.`, then `this` or an identifier +/// followed by one to three `.identifier` segments. +fn kotlin_receiver_chain(text: &str) -> Option { + let chain = strip_js_ws(text).replace("?.", "."); + let segments: Vec<&str> = chain.split('.').collect(); + if segments.len() < 2 || segments.len() > 4 { + return None; + } + let ident = |s: &str| { + let b = s.as_bytes(); + !b.is_empty() + && (b[0].is_ascii_alphabetic() || b[0] == b'_') + && b.iter().all(|c| c.is_ascii_alphanumeric() || *c == b'_') + }; + if segments.iter().all(|s| ident(s)) { + Some(chain) + } else { + None + } +} + /// A property's CODE children: the named child right after the `=` token, a /// `property_delegate` (`by lazy { … }`), and an accessor the grammar nested /// under the declaration (`val x: Int get() = compute()` — written on ONE line; @@ -1257,9 +1279,17 @@ impl<'t> Walker<'t> { } else { method_name.to_string() }; + } else if let Some(chain) = receiver + .filter(|r| r.kind() == "navigation_expression") + .and_then(|r| kotlin_receiver_chain(self.text(r))) + { + // Receiver chain `a.b` / `this.a` / `a?.b`: kept for the + // resolver to type through the properties' declared types. + callee_name = format!("{chain}.{method_name}"); } else { - // this_expression / super_expression / 2-hop nav / - // postfix `!!` / parenthesized → bare method name. + // this_expression / super_expression / a longer or + // non-identifier chain / postfix `!!` / parenthesized → + // bare method name. callee_name = method_name.to_string(); } } diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 5c27523acc..740d9dcbea 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -406,6 +406,8 @@ const LITERAL_RECEIVER_TYPES = new Set([ /** * Languages whose member calls go through the TS/JS grammars. */ +/** A Kotlin receiver chain the resolver can type: `a.b`, `this.a`, up to four segments. */ +const KOTLIN_RECEIVER_CHAIN = /^(?:this|[A-Za-z_]\w*)(?:\.[A-Za-z_]\w*){1,3}$/; const TS_JS_CHAIN_LANGUAGES = new Set(['typescript', 'tsx', 'javascript', 'jsx']); /** Receiver node types (TS/JS grammars) that continue a member chain downward. */ @@ -5074,6 +5076,21 @@ export class TreeSitterExtractor { // Go receivers resolve strictly via validated field-hop // inference (see matchGoFieldChainCall) or stay unresolved. calleeName = `${getNodeText(receiver, this.source).replace(/\s+/g, '')}.${methodName}`; + } else if ( + this.language === 'kotlin' && + receiver && + receiver.type === 'navigation_expression' && + KOTLIN_RECEIVER_CHAIN.test(getNodeText(receiver, this.source).replace(/\s+/g, '').replace(/\?\./g, '.')) + ) { + // Kotlin call through a receiver chain — `engine.pump.drain()`, + // `this.engine.drain()`, `a?.b?.c()`. Keep the chain: the + // resolver types it segment by segment through the properties' + // declared types and resolves the method on that type, leaves a + // library type unresolved, and resolves an untyped chain as the + // bare method name this used to emit. Mirrored in the kernel's + // extract_call (kotlin.rs). + const chain = getNodeText(receiver, this.source).replace(/\s+/g, '').replace(/\?\./g, '.'); + calleeName = `${chain}.${methodName}`; } else if ( TS_JS_CHAIN_LANGUAGES.has(this.language) && receiver && diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 1eaf14b145..596866a01e 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -21,7 +21,7 @@ import { isImportableKind, CPP_DEFINE_SIGNATURE, } from './types'; -import { isPythonSelfCall, matchJsStoreBindingCall, isUnresolvedJsMemberCall, isVisibleAcrossFiles, matchReference, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesCodeBoundary, gateLanguageMatch, dumpNameMatcherProfile, clearNameMatcherMemos, isRustNameInScope, CASE_INSENSITIVE_LANGUAGES } from './name-matcher'; +import { matchKotlinReceiverChain, isPythonSelfCall, matchJsStoreBindingCall, isUnresolvedJsMemberCall, isVisibleAcrossFiles, matchReference, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesCodeBoundary, gateLanguageMatch, dumpNameMatcherProfile, clearNameMatcherMemos, isRustNameInScope, CASE_INSENSITIVE_LANGUAGES } from './name-matcher'; import { isVisibleCppMacro, clearCppMacroVisibility } from './cpp-macro-visibility'; import { isCppConstructorRef, matchCppConstructor } from './cpp-constructor'; import { gateSwiftTypeTarget, clearSwiftTypeVisibility, swiftExtendedConformances } from './swift-type-visibility'; @@ -1068,6 +1068,18 @@ export class ReferenceResolver { // A local C++ object construction (`T obj(args)`, ref `ns::T::T/1`) // resolves ONLY to a constructor of the lexically nearest `T` (#1839). if (isCppConstructorRef(ref)) return matchCppConstructor(ref, this.context); + // A Kotlin call through a receiver chain (`engine.pump.drain()`) resolves + // on the chain's declared type, or gets no edge when that type is a + // library one. An untyped chain resolves as the bare method name, the ref + // the extractor emitted before it kept the chain. + if (ref.language === 'kotlin' && ref.referenceKind === 'calls') { + const chain = matchKotlinReceiverChain(ref, this.context); + if (chain && 'method' in chain) { + const bare = this.resolveOneInner({ ...ref, referenceName: chain.method }); + return bare ? { ...bare, original: ref } : null; + } + if (chain !== undefined) return this.gateLanguage(chain, ref); + } // Skip built-in/external references if (this.isBuiltInOrExternal(ref)) { diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 6694cd3835..01e987ef31 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -6617,7 +6617,8 @@ function kotlinDeclaredTypeIn( return kotlinReceiverDeclaredType(alias[1], ref, context, depth + 1); } // `(` or a trailing lambda `{`: `Thread { … }`, `thread { … }`. - const assigned = text.match(new RegExp(`\\b(?:val|var)\\s+${r}\\s*=\\s*([A-Za-z_][\\w.]*)\\s*[({]`)); + // Type arguments are skipped: `WebRtcPlaneSession(plane)`. + const assigned = text.match(new RegExp(`\\b(?:val|var)\\s+${r}\\s*=\\s*([A-Za-z_][\\w.]*)\\s*(?:<[\\w\\s.,?*<>]*>\\s*)?[({]`)); if (!assigned || !assigned[1]) { // An enum entry or other constant of a type: `var mode = GpsMode.ON`. const entry = text.match(new RegExp(`\\b(?:val|var)\\s+${r}\\s*=\\s*([A-Z]\\w*(?:\\.[A-Z]\\w*)*)\\.[A-Z][A-Z0-9_]*\\b(?!\\s*[.(])`)); @@ -6748,6 +6749,115 @@ function kotlinReceiverDeclaredType( return param && /^[A-Z]/.test(param) ? param : null; } +/** + * The declared type of a Kotlin receiver chain `a.b.c` / `this.a.b`: the first + * value typed as a single receiver (`this` is the enclosing class, a type name + * its object or companion), each next segment as a property or enum entry + * declared in the class of the type before it, read from that class's own + * file. KOTLIN_EXTERNAL_TYPE as soon as a segment has a + * type the project does not declare; null when any segment's type is unknown + * (an inherited property, an ambiguous class name). + */ +function kotlinChainDeclaredType( + receiver: string, + ref: UnresolvedRef, + context: ResolutionContext, +): string | null { + const segments = receiver.split('.'); + if (segments.length < 2 || segments.length > 4) return null; + + let owner: Node | undefined; + if (segments[0] === 'this') { + owner = context.getNodesInFile(ref.filePath) + .filter((n) => (n.kind === 'class' || n.kind === 'interface') && n.language === 'kotlin' && + n.startLine <= ref.line && (n.endLine ?? n.startLine) >= ref.line) + .sort((a, b) => b.startLine - a.startLine)[0]; + if (!owner) return null; + } + let type: string | null = owner ? null : kotlinReceiverDeclaredType(segments[0]!, ref, context); + if (!owner && !type && /^[A-Z]/.test(segments[0]!)) { + // An object or a class's companion: `Registry.current.load()`, + // `Mode.ON.next()`. + const classes = context.getNodesByName(segments[0]!).filter( + (n) => DECLARED_TYPE_KINDS.has(n.kind) && n.language === 'kotlin', + ); + if (classes.length !== 1) return null; + owner = classes[0]!; + } + for (const segment of segments.slice(1)) { + if (!owner) { + if (!type) return null; + if (type === KOTLIN_EXTERNAL_TYPE || !isProjectType(type, ref, context)) return KOTLIN_EXTERNAL_TYPE; + const t = type; + const classes = context.getNodesByName(t.split('::').pop()!).filter( + (n) => DECLARED_TYPE_KINDS.has(n.kind) && n.language === 'kotlin' && + (n.qualifiedName === t || n.qualifiedName.endsWith(`::${t}`)), + ); + if (classes.length !== 1) return null; + owner = classes[0]!; + } + const inFile = context.getNodesInFile(owner.filePath); + const qualified = `${owner.qualifiedName}::${segment}`; + if (inFile.some((n) => n.kind === 'enum_member' && n.qualifiedName === qualified)) { + // An enum entry has its enum's type (`Outer::Mode`, package dropped). + type = kotlinTypeName(owner.qualifiedName.split('::').join('.')); + owner = undefined; + continue; + } + const property = inFile.find( + (n) => (n.kind === 'field' || n.kind === 'constant' || n.kind === 'variable') && n.qualifiedName === qualified, + ) ?? null; + // Read the declaration in the owner's file, as if the ref sat there. + const at: UnresolvedRef = { ...ref, filePath: owner.filePath, line: property?.startLine ?? owner.startLine }; + type = inferKotlinPropertyType(segment, property, owner, inFile, at, context); + owner = undefined; + } + return type; +} + +/** A Kotlin call through a receiver chain: `a.b.m`, `this.a.m`, `a.b.c.d.m`. */ +const KOTLIN_CHAIN_REF = /^((?:this|[A-Za-z_]\w*)(?:\.[A-Za-z_]\w*){1,3})\.(\w+)$/; + +/** + * A Kotlin call through a receiver chain (`engine.pump.drain()`), resolved on + * the chain's declared type. Null — no edge — when the chain ends in a type + * the project does not declare (or passes through one) and the project has + * no extension of that name on a library type. When the type is + * unknown, `{ method }` names the bare method for the caller to resolve, the + * ref the extractor emitted before it kept the chain. Undefined for a ref + * that is not a receiver chain. + */ +export function matchKotlinReceiverChain( + ref: UnresolvedRef, + context: ResolutionContext, +): ResolvedRef | null | { method: string } | undefined { + const m = ref.referenceName.match(KOTLIN_CHAIN_REF); + if (!m) return undefined; + const method = m[2]!; + const declared = kotlinChainDeclaredType(m[1]!, ref, context); + if (!declared) return { method }; + if (declared !== KOTLIN_EXTERNAL_TYPE) { + const typed = resolveMethodOnType(declared, method, ref, context, 0.9, 'instance-method'); + if (typed) return typed; + if (isProjectType(declared, ref, context)) return { method }; + } + // A library type still reaches the project's own extensions of library + // types (`view.context.dp(8)` → `fun Context.dp()`). + return kotlinDeclaresLibraryExtension(method, ref, context) ? { method } : null; +} + +/** + * Whether the project declares a Kotlin extension `method` on a type it does + * not declare itself (`fun Context.dp(…)`, indexed as `Context::dp`). + */ +function kotlinDeclaresLibraryExtension(method: string, ref: UnresolvedRef, context: ResolutionContext): boolean { + return context.getNodesByName(method).some((n) => { + if ((n.kind !== 'method' && n.kind !== 'function') || n.language !== 'kotlin') return false; + const owner = n.qualifiedName.split('::').slice(-2, -1)[0]; + return !!owner && /^[A-Z]/.test(owner) && !isProjectType(owner, ref, context); + }); +} + // ── Local-variable receiver-type inference (#1108) ────────────────────────── // // Instance calls through a local variable (`const lg = new Logger(); lg.log()`) From 7d45a1fee73e79ede1f53944b493de26df952cc6 Mon Sep 17 00:00:00 2001 From: danusha2345 Date: Thu, 24 Sep 2026 16:38:34 +0300 Subject: [PATCH 07/15] fix(kotlin): type inherited properties and outer locals captured by anonymous objects A receiver (or chain segment) that the enclosing class does not declare is looked up in its project supertypes, read from each class header (breadth- first, at most four levels); a library or ambiguous supertype is not walked, so the receiver stays unknown and keeps the name-only resolution. The local declaration scan continues from a function nested in another (an anonymous object's member, a local function) into the outer function above it, skipping sibling functions whose locals are not visible. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- __tests__/kotlin-property-receiver.test.ts | 81 ++++++++ src/resolution/name-matcher.ts | 220 ++++++++++++++++++--- 3 files changed, 274 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e30e4abea..bf840c10fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -262,7 +262,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - The Claude Code prompt hook no longer runs on the task-notification messages Claude Code injects when a background agent finishes, removing a multi-second stall on every such turn. (#1832) -- Kotlin calls through a class property, a primary-constructor property, or a variable set from a function call now resolve on the declared type, and a call on a library type (such as `Regex` or a JDK class) no longer links to an unrelated project method with the same name. Chained receivers such as `engine.pump.drain()`, `this.engine.drain()` and `Mode.ON.next()` resolve the same way. Re-index Kotlin projects to pick this up. +- Kotlin calls through a class property, a primary-constructor property, or a variable set from a function call now resolve on the declared type, and a call on a library type (such as `Regex` or a JDK class) no longer links to an unrelated project method with the same name. Chained receivers such as `engine.pump.drain()`, `this.engine.drain()` and `Mode.ON.next()` resolve the same way, as do properties inherited from a project base class and variables of the enclosing function used inside an anonymous `object : …`. Re-index Kotlin projects to pick this up. - Kotlin `fun interface` declarations are indexed, and no longer hide the declaration that follows them. - Rust calls on `self` now stay with the enclosing type instead of linking to an unrelated type’s same-named method. Thanks @L4XB. (#1861) diff --git a/__tests__/kotlin-property-receiver.test.ts b/__tests__/kotlin-property-receiver.test.ts index 7089f1ceb3..dcc6e2fac8 100644 --- a/__tests__/kotlin-property-receiver.test.ts +++ b/__tests__/kotlin-property-receiver.test.ts @@ -185,6 +185,54 @@ class Owner(private val engine: Motor, private val site: Plant) { fun library() { engine.runner.start() } fun extension() { engine.runner.label() } } +`); + // A property a class inherits: from a base in another file (a body + // property and a primary-constructor one, two levels up), or in the same + // file. Variable names differ from type names so no capitalized-name + // guess can land on the right type. + fs.writeFileSync(path.join(src, 'Bases.kt'), `package p +class Ledger { fun post() {} } +class Feed { fun refresh() {} } +abstract class BaseScreen(protected val source: Feed) : java.io.Serializable { + protected lateinit var model: Ledger +} +open class MidScreen(origin: Feed) : BaseScreen(origin) +`); + fs.writeFileSync(path.join(src, 'Screens.kt'), `package p +class Cache { fun post() {}; fun refresh() {}; fun twist() {}; fun open() {} } +class Knob { fun twist() {} } +class Sink { fun drainAll() {} } +open class Panel { val widget = Knob() } +class SidePanel : Panel() { + fun turn() { widget.twist() } +} +class HomeScreen(start: Feed) : MidScreen(start), Runnable { + override fun run() {} + fun show() { model.post() } + fun pull() { source.refresh() } + fun viaThis() { this.model.post() } +} +class LibScreen : android.app.Activity() { + fun go() { helper.drainAll() } +} +abstract class Hook { abstract fun fire(code: Int) } +class Gate { fun open() {} } +class Rig { val gate = Gate() } +class Wiring { + fun wire(): Hook { + val motor = Rig() + return object : Hook() { + override fun fire(code: Int) { motor.gate.open() } + } + } + fun siblings(): Hook { + val latch = Gate() + return object : Hook() { + fun prep() { val latch = Cache() } + override fun fire(code: Int) { latch.open() } + } + } +} `); cg = CodeGraph.initSync(dir, { config: { include: ['**/*.kt'], exclude: [] } }); await cg.indexAll(); @@ -285,6 +333,39 @@ class Owner(private val engine: Motor, private val site: Plant) { expect(callees('Owner', 'extension')).toEqual(['Thread::label']); }); + it('a property inherited from a superclass is typed, in the same file or another', () => { + expect(callees('SidePanel', 'turn')).toEqual(['p::Knob::twist']); + // Two levels up, past a library interface in the supertype list. + expect(callees('HomeScreen', 'show')).toEqual(['p::Ledger::post']); + expect(callees('HomeScreen', 'viaThis')).toEqual(['p::Ledger::post']); + // A primary-constructor property of the base. + expect(callees('HomeScreen', 'pull')).toEqual(['p::Feed::refresh']); + }); + + it('a receiver not found up to a library base class keeps the name-only resolution', () => { + // The property may come from the library base, so it is neither typed + // nor treated as a library type. + expect(callees('LibScreen', 'go')).toEqual(['p::Sink::drainAll']); + }); + + it('a local of the outer function is typed inside an anonymous object', () => { + /** Callees of the `fire` override declared inside `Wiring::`. */ + const fire = (fn: string): string[] => { + const outer = cg.searchNodes(fn).map((r) => r.node).find((n) => n.qualifiedName.endsWith(`Wiring::${fn}`)); + expect(outer, fn).toBeDefined(); + const node = cg.searchNodes('fire').map((r) => r.node).find( + (n) => n.filePath.endsWith('Screens.kt') && n.startLine > outer!.startLine && n.startLine <= (outer!.endLine ?? outer!.startLine), + ); + expect(node, `${fn} fire`).toBeDefined(); + return cg.getCallees(node!.id) + .filter((c) => c.edge.kind === 'calls' && !c.edge.metadata?.synthesizedBy) + .map((c) => c.node.qualifiedName); + }; + expect(fire('wire')).toEqual(['p::Gate::open']); + // A sibling member's same-named local is not visible from `fire`. + expect(fire('siblings')).toEqual(['p::Gate::open']); + }); + it('a nested type keeps its outer type', () => { expect(callees('NestedUser', 'release')).toEqual(['p::HardwareLock::Lease::close']); }); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 01e987ef31..a22550979c 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -6531,17 +6531,29 @@ function inferKotlinPropertyType( ref: UnresolvedRef, context: ResolutionContext, ): string | null { - const lines = context.getFileLines - ? context.getFileLines(ref.filePath) - : (context.readFile(ref.filePath)?.split(/\r?\n/) ?? null); - if (!lines || lines.length === 0) return null; + const lines = kotlinFileLines(ref.filePath, context); + if (!lines) return null; - const readType = (from: number, to: number): string | null => - kotlinDeclaredTypeIn( - lines.slice(Math.max(0, from - 1), Math.min(lines.length, to)).join('\n'), name, ref, context); + if (property) { + const text = lines.slice(Math.max(0, property.startLine - 1), Math.min(lines.length, property.endLine ?? property.startLine)); + return kotlinDeclaredTypeIn(text.join('\n'), name, ref, context); + } + return kotlinDeclaredTypeIn(kotlinClassHeader(owner, inFile, lines), name, ref, context); +} - if (property) return readType(property.startLine, property.endLine ?? property.startLine); +/** A file's lines through the context's per-file cache; null when unreadable. */ +function kotlinFileLines(filePath: string, context: ResolutionContext): string[] | null { + const lines = context.getFileLines + ? context.getFileLines(filePath) + : (context.readFile(filePath)?.split(/\r?\n/) ?? null); + return lines && lines.length > 0 ? lines : null; +} +/** + * A Kotlin class's lines before its first member: its annotations, name, + * primary constructor and supertype list. + */ +function kotlinClassHeader(owner: Node, inFile: Node[], lines: string[]): string { const ownerEnd = owner.endLine ?? owner.startLine; let firstMember = ownerEnd + 1; for (const n of inFile) { @@ -6550,7 +6562,117 @@ function inferKotlinPropertyType( firstMember = n.startLine; } } - return readType(owner.startLine, firstMember - 1); + return lines.slice(Math.max(0, owner.startLine - 1), Math.min(lines.length, firstMember - 1)).join('\n'); +} + +/** + * The supertype names a Kotlin class header lists after its primary + * constructor (`class A(…) : Base(…), Iface, Api by impl {`), as + * kotlinTypeName gives them. Colons and commas inside the constructor or + * type arguments are skipped; a `where` clause or the body ends the list. + */ +function kotlinSupertypeNames(header: string, className: string): string[] { + const r = className.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const decl = new RegExp(`\\b(?:class|object|interface)\\s+${r}\\b`).exec(header); + if (!decl) return []; + const entries: string[] = []; + let paren = 0; + let angle = 0; + let start = -1; + let i = decl.index + decl[0].length; + for (; i < header.length; i++) { + const c = header[i]!; + if (c === '(') paren++; + else if (c === ')') paren--; + else if (paren > 0) continue; + else if (c === '<') angle++; + else if (c === '>') angle--; + else if (angle > 0) continue; + else if (c === '{') break; + else if (start >= 0 && c === 'w' && /^where\b/.test(header.slice(i, i + 6)) && !/\w/.test(header[i - 1] ?? '')) break; + else if (c === ':' && start < 0) start = i + 1; + else if (c === ',' && start >= 0) { + entries.push(header.slice(start, i)); + start = i + 1; + } + } + if (start >= 0) entries.push(header.slice(start, i)); + const names: string[] = []; + for (const entry of entries) { + const m = entry.match(/^\s*(?:@\w+\s+)*([A-Za-z_][\w.]*)/); + const type = m && m[1] ? kotlinTypeName(m[1]) : null; + if (type) names.push(type); + } + return names; +} + +/** + * The declared type of Kotlin property `name` if class `owner` declares it — + * as a member node or a primary-constructor `val`/`var` — read in the + * owner's own file. Undefined when the owner does not declare it; null when + * it does without a type the declaration names. + */ +function kotlinOwnPropertyType( + name: string, + owner: Node, + ref: UnresolvedRef, + context: ResolutionContext, +): string | null | undefined { + const inFile = context.getNodesInFile(owner.filePath); + const qualified = `${owner.qualifiedName}::${name}`; + const property = inFile.find( + (n) => (n.kind === 'field' || n.kind === 'constant' || n.kind === 'variable') && n.qualifiedName === qualified, + ) ?? null; + if (!property) { + const lines = kotlinFileLines(owner.filePath, context); + const r = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + if (!lines || !new RegExp(`\\b(?:val|var)\\s+${r}\\b`).test(kotlinClassHeader(owner, inFile, lines))) { + return undefined; + } + } + // Read the declaration in the owner's file, as if the ref sat there. + const at: UnresolvedRef = { ...ref, filePath: owner.filePath, line: property?.startLine ?? owner.startLine }; + return inferKotlinPropertyType(name, property, owner, inFile, at, context); +} + +/** + * The declared type of Kotlin property `name` that class `owner` inherits: the + * nearest project supertype (breadth-first, at most four levels) declaring it, + * with supertypes read from each class header. A supertype the project does + * not declare, or whose name is ambiguous, is not walked — the property may + * come from it — so the result is then null (unknown), never external. + */ +function kotlinInheritedPropertyType( + name: string, + owner: Node, + ref: UnresolvedRef, + context: ResolutionContext, +): string | null { + const seen = new Set([owner.id]); + let level: Node[] = [owner]; + for (let depth = 0; depth < 4 && level.length > 0; depth++) { + const next: Node[] = []; + for (const cls of level) { + const lines = kotlinFileLines(cls.filePath, context); + if (!lines) continue; + const header = kotlinClassHeader(cls, context.getNodesInFile(cls.filePath), lines); + for (const superName of kotlinSupertypeNames(header, cls.name)) { + const simple = superName.split('::').pop()!; + const supers = context.getNodesByName(simple).filter( + (n) => (n.kind === 'class' || n.kind === 'interface') && n.language === 'kotlin' && + (n.qualifiedName === superName || n.qualifiedName.endsWith(`::${superName}`)), + ); + if (supers.length !== 1 || seen.has(supers[0]!.id)) continue; + const sup = supers[0]!; + seen.add(sup.id); + const type = kotlinOwnPropertyType(name, sup, ref, context); + if (type !== undefined) return type; + next.push(sup); + } + } + level = next; + } + return null; } @@ -6717,7 +6839,8 @@ function kotlinCallResultType( /** * The declared type of Kotlin receiver `name` at `ref`: the nearest local * declaration in the enclosing scope (typed, constructed, or bound to a call), - * else a property of the enclosing class. Null when none names a type. + * else a property of the enclosing class, else a parameter, else a property + * the enclosing class inherits. Null when none names a type. */ function kotlinReceiverDeclaredType( name: string, @@ -6725,19 +6848,17 @@ function kotlinReceiverDeclaredType( context: ResolutionContext, depth = 0, ): string | null { - const lines = context.getFileLines - ? context.getFileLines(ref.filePath) - : (context.readFile(ref.filePath)?.split(/\r?\n/) ?? null); - if (lines && lines.length > 0) { + const lines = kotlinFileLines(ref.filePath, context); + let localDeclared = false; + if (lines) { const r = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const declares = new RegExp(`\\b(?:val|var)\\s+${r}\\b`); - const callIdx = Math.max(0, Math.min(lines.length - 1, ref.line - 1)); - const startIdx = Math.max(0, enclosingScopeStartLine(ref, context) - 1); - for (let i = callIdx; i >= startIdx; i--) { + for (const i of kotlinLocalScanLines(ref, context, lines.length)) { const line = lines[i]; if (!line || line.length > 10_000 || !declares.test(line)) continue; const local = kotlinDeclaredTypeIn(line, name, ref, context, depth); if (local) return local; + localDeclared = true; break; } } @@ -6746,7 +6867,57 @@ function kotlinReceiverDeclaredType( // A typed parameter of the enclosing function or lambda (`webSocket: // WebSocket` in an overridden callback). const param = inferLocalReceiverType(name, ref, context); - return param && /^[A-Z]/.test(param) ? param : null; + if (param) return /^[A-Z]/.test(param) ? param : null; + if (localDeclared) return null; + // A property of a superclass: `viewModel` declared in `BaseActivity`. + const owner = kotlinEnclosingClass(ref, context); + if (!owner || kotlinOwnPropertyType(name, owner, ref, context) !== undefined) return null; + return kotlinInheritedPropertyType(name, owner, ref, context); +} + +/** The tightest Kotlin class or interface enclosing `ref`'s line. */ +function kotlinEnclosingClass(ref: UnresolvedRef, context: ResolutionContext): Node | undefined { + return context.getNodesInFile(ref.filePath) + .filter((n) => (n.kind === 'class' || n.kind === 'interface') && n.language === 'kotlin' && + n.startLine <= ref.line && (n.endLine ?? n.startLine) >= ref.line) + .sort((a, b) => b.startLine - a.startLine)[0]; +} + +/** + * The 0-based lines a Kotlin local visible at `ref` may be declared on, + * nearest first: the enclosing function above the call, then — when that + * function is nested in another (a member of an anonymous object, a local + * function) — each outer function above the nested one, whose locals it + * captures. Lines of a function nested in a scanned one that does not + * enclose `ref` are skipped: its locals are not visible. Outside any + * function, every line above the call. + */ +function kotlinLocalScanLines(ref: UnresolvedRef, context: ResolutionContext, lineCount: number): number[] { + const callIdx = Math.max(0, Math.min(lineCount - 1, ref.line - 1)); + const fns = context.getNodesInFile(ref.filePath).filter( + (n) => (n.kind === 'function' || n.kind === 'method') && n.language === 'kotlin', + ); + const enclosing = fns + .filter((n) => n.startLine <= ref.line && (n.endLine ?? n.startLine) >= ref.line) + .sort((a, b) => b.startLine - a.startLine); + const out: number[] = []; + if (enclosing.length === 0) { + for (let i = callIdx; i >= 0; i--) out.push(i); + return out; + } + let hi = callIdx; + for (const fn of enclosing) { + const fnEnd = fn.endLine ?? fn.startLine; + const hidden = fns.filter( + (n) => n.startLine > fn.startLine && (n.endLine ?? n.startLine) <= fnEnd && + !(n.startLine <= ref.line && (n.endLine ?? n.startLine) >= ref.line), + ); + for (let i = hi; i >= fn.startLine - 1; i--) { + if (!hidden.some((n) => i + 1 >= n.startLine && i + 1 <= (n.endLine ?? n.startLine))) out.push(i); + } + hi = Math.min(hi, fn.startLine - 2); + } + return out; } /** @@ -6768,10 +6939,7 @@ function kotlinChainDeclaredType( let owner: Node | undefined; if (segments[0] === 'this') { - owner = context.getNodesInFile(ref.filePath) - .filter((n) => (n.kind === 'class' || n.kind === 'interface') && n.language === 'kotlin' && - n.startLine <= ref.line && (n.endLine ?? n.startLine) >= ref.line) - .sort((a, b) => b.startLine - a.startLine)[0]; + owner = kotlinEnclosingClass(ref, context); if (!owner) return null; } let type: string | null = owner ? null : kotlinReceiverDeclaredType(segments[0]!, ref, context); @@ -6804,12 +6972,8 @@ function kotlinChainDeclaredType( owner = undefined; continue; } - const property = inFile.find( - (n) => (n.kind === 'field' || n.kind === 'constant' || n.kind === 'variable') && n.qualifiedName === qualified, - ) ?? null; - // Read the declaration in the owner's file, as if the ref sat there. - const at: UnresolvedRef = { ...ref, filePath: owner.filePath, line: property?.startLine ?? owner.startLine }; - type = inferKotlinPropertyType(segment, property, owner, inFile, at, context); + const own = kotlinOwnPropertyType(segment, owner, ref, context); + type = own !== undefined ? own : kotlinInheritedPropertyType(segment, owner, ref, context); owner = undefined; } return type; From 8abb299fb5988ec4d5c4f84c83842d867f0b971e Mon Sep 17 00:00:00 2001 From: mixxer Date: Thu, 24 Sep 2026 12:53:25 +0900 Subject: [PATCH 08/15] fix(java-kotlin): preserve anonymous inheritance and resolve nested types --- CHANGELOG.md | 1 + __tests__/extraction.test.ts | 44 ++++++ __tests__/kotlin-object-literal.test.ts | 108 +++++++++++++++ __tests__/resolution.test.ts | 140 +++++++++++++++++++ codegraph-kernel/src/java.rs | 27 +++- codegraph-kernel/src/kotlin.rs | 156 ++++++++++++++++++++- src/extraction/tree-sitter.ts | 173 ++++++++++++++++++++++-- src/resolution/name-matcher.ts | 79 +++++++++-- 8 files changed, 702 insertions(+), 26 deletions(-) create mode 100644 __tests__/kotlin-object-literal.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index bf840c10fa..c13a5ae1b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -264,6 +264,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Kotlin calls through a class property, a primary-constructor property, or a variable set from a function call now resolve on the declared type, and a call on a library type (such as `Regex` or a JDK class) no longer links to an unrelated project method with the same name. Chained receivers such as `engine.pump.drain()`, `this.engine.drain()` and `Mode.ON.next()` resolve the same way, as do properties inherited from a project base class and variables of the enclosing function used inside an anonymous `object : …`. Re-index Kotlin projects to pick this up. - Kotlin `fun interface` declarations are indexed, and no longer hide the declaration that follows them. +- Java and Kotlin inheritance now resolves nested types more precisely, and anonymous implementations retain their members after re-indexing. - Rust calls on `self` now stay with the enclosing type instead of linking to an unrelated type’s same-named method. Thanks @L4XB. (#1861) diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index c6a522decf..97f2badfd6 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -1916,6 +1916,50 @@ class T { expect(callersOf('target')).toEqual(['directCall', 'fieldLambda', 'run']); expect(callersOf('compute')).toEqual(['eager']); }); + + it('keeps the full qualified name on the extends reference of `new Outer.Inner() { ... }` (real AOSP AIDL shape: `new ICarPropertyEventListener.Stub() { ... }`)', () => { + // The anon class's OWN name and the enclosing `instantiates` edge are + // correctly truncated to the bare last segment ("Stub") — that matches + // how a real in-project nested class's own node is named, and how + // `instantiates` resolves (by bare class name). But the `extends` + // reference is a DIFFERENT resolution path: a real named class's + // `extends IFoo.Stub` clause is extracted verbatim (untruncated), and + // AOSP-style qualified lookups (hal.ts/aidl.ts's `IFoo`/`IFoo.%` prefix + // match) depend on that full dotted text surviving. Truncating the + // anon-class extends reference to "Stub" the same way made a real field + // initializer like `new ICarPropertyEventListener.Stub() { ... }` + // invisible to `aidl-impl` even after the anon body itself was extracted + // (found on the real platform_packages_services_car AOSP mirror, + // 2026-09-11). + const code = ` +package p; +interface ICarPropertyEventListener { + interface Stub {} +} +class CarNightService { + private final ICarPropertyEventListener mListener = + new ICarPropertyEventListener.Stub() { + public void onEvent() {} + }; +} +`; + const result = extractFromSource('CarNightService.java', code); + const anon = result.nodes.find((n) => n.kind === 'class' && /Stub\$anon@/.test(n.name)); + expect(anon, 'anonymous Stub subclass should be extracted as a class').toBeDefined(); + + const extendsRef = result.unresolvedReferences.find( + (r) => r.referenceKind === 'extends' && r.fromNodeId === anon!.id + ); + expect(extendsRef, 'anon class should carry an extends reference').toBeDefined(); + expect(extendsRef!.referenceName).toBe('ICarPropertyEventListener.Stub'); + + // The anon class's own cosmetic name and the instantiates edge stay + // truncated to the bare last segment — unaffected by this fix. + const instantiatesRef = result.unresolvedReferences.find( + (r) => r.referenceKind === 'instantiates' && r.referenceName === 'Stub' + ); + expect(instantiatesRef, 'enclosing field should still instantiate the bare Stub name').toBeDefined(); + }); }); describe('C# Extraction', () => { diff --git a/__tests__/kotlin-object-literal.test.ts b/__tests__/kotlin-object-literal.test.ts new file mode 100644 index 0000000000..82f5337213 --- /dev/null +++ b/__tests__/kotlin-object-literal.test.ts @@ -0,0 +1,108 @@ +/** Kotlin anonymous objects: negative shapes and traversal/ownership regressions. */ +import { describe, it, expect, beforeAll } from 'vitest'; +import { TreeSitterExtractor } from '../src/extraction/tree-sitter'; +import { initGrammars, loadGrammarsForLanguages, getParser } from '../src/extraction/grammars'; + +beforeAll(async () => { + await initGrammars(); + await loadGrammarsForLanguages(['kotlin']); +}); + +function extract(source: string) { + const tree = getParser('kotlin')!.parse(source)!; + expect(tree.rootNode.hasError, tree.rootNode.toString()).toBe(false); + tree.delete(); + const result = new TreeSitterExtractor('src/Probe.kt', source, 'kotlin').extract(); + expect(result.errors).toEqual([]); + return result; +} + +const classes = (r: ReturnType) => r.nodes.filter(n => n.kind === 'class' && n.name.includes('$anon@')); +const refs = (r: ReturnType, kind: string) => r.unresolvedReferences.filter(r => r.referenceKind === kind); + +describe('Kotlin object literals', () => { + it.each(['val value = ', 'fun create() = '])('keeps constructor argument calls in the enclosing scope: %s', prefix => { + const r = extract(`${prefix}object : IFoo.Stub(computeArg()) { override fun run() { bodyCall() }; }`); + const arg = refs(r, 'calls').filter(x => x.referenceName === 'computeArg'); + expect(arg).toHaveLength(1); + expect(arg[0]!.fromNodeId).not.toBe(classes(r)[0]!.id); + const run = r.nodes.find(n => n.name === 'run')!; + expect(refs(r, 'calls').find(x => x.referenceName === 'bodyCall')?.fromNodeId).toBe(run.id); + }); + + it('extracts delegated interface identity without treating delegate calls as supertypes', () => { + const r = extract('fun create() = object : api.IFoo by (makeDelegate()), Marker {}'); + expect(refs(r, 'extends').map(x => x.referenceName)).toEqual(['api.IFoo', 'Marker']); + expect(refs(r, 'instantiates')).toEqual([]); + expect(refs(r, 'calls').filter(x => x.referenceName === 'makeDelegate')).toHaveLength(1); + }); + + it('keeps generic outer and inner type segments while excluding type arguments', () => { + const r = extract('val x = object : pkg.Outer.Inner>() {}'); + expect(refs(r, 'extends').map(x => x.referenceName)).toEqual(['pkg.Outer.Inner']); + expect(refs(r, 'instantiates').map(x => x.referenceName)).toEqual(['pkg.Outer.Inner']); + }); + + it('preserves nested members and calls of objects without explicit supertypes', () => { + const r = extract(`fun create() = object { + fun outer() { outerCall() } + val child = object : IFoo.Stub() { + override fun inner() { innerCall() } + } + }`); + expect(classes(r)).toHaveLength(2); + for (const name of ['outer', 'inner']) { + const members = r.nodes.filter(n => n.name === name); + expect(members).toHaveLength(1); + expect(refs(r, 'calls').find(x => x.referenceName === `${name}Call`)?.fromNodeId).toBe(members[0]!.id); + expect(r.edges.filter(e => e.kind === 'contains' && e.target === members[0]!.id)).toHaveLength(1); + } + }); + + it('does not merge two same-supertype anonymous objects on one line', () => { + const r = extract('val x = object : Base() { val child = object : Base() {}; }'); + expect(classes(r)).toHaveLength(2); + expect(new Set(classes(r).map(n => n.id)).size).toBe(2); + expect(refs(r, 'extends').map(x => x.fromNodeId)).toEqual(classes(r).map(n => n.id)); + }); + + it('keeps same-line same-name members in distinct anonymous owners', () => { + const r = extract('val x = object : Base() { fun run() { outerCall() }; val child = object : Base() { fun run() { innerCall() }; }; }'); + const members = r.nodes.filter(n => n.name === 'run'); + expect(members).toHaveLength(2); + expect(new Set(members.map(n => n.id)).size).toBe(2); + expect(refs(r, 'calls').find(x => x.referenceName === 'outerCall')?.fromNodeId).toBe(members[0]!.id); + expect(refs(r, 'calls').find(x => x.referenceName === 'innerCall')?.fromNodeId).toBe(members[1]!.id); + }); + + it('visits an anonymous object in a constructor argument exactly once', () => { + const r = extract(`fun create() = object : Base(object : Callback { + override fun invoke() { callbackCall() } + }) { fun own() { ownCall() }; }`); + expect(classes(r)).toHaveLength(2); + expect(r.nodes.filter(n => n.name === 'invoke')).toHaveLength(1); + expect(refs(r, 'calls').filter(x => x.referenceName === 'callbackCall')).toHaveLength(1); + }); + + it('chooses the sole constructor after an interface and ignores generic argument decoys', () => { + const r = extract('val x = object : Marker, pkg.Real.Stub(), Other {}'); + expect(refs(r, 'extends').map(x => x.referenceName)).toEqual(['Marker', 'pkg.Real.Stub', 'Other']); + expect(refs(r, 'instantiates').map(x => x.referenceName)).toEqual(['pkg.Real.Stub']); + }); + + it('does not classify companion or named objects as anonymous literals', () => { + const r = extract(`class Host { + companion object : Marker {} + object Named : Marker {} + val value = object : Marker { fun run() {}; } + }`); + expect(classes(r)).toHaveLength(1); + expect(r.nodes.filter(n => n.name === 'run')).toHaveLength(1); + }); + + it('does not throw for incomplete objects or syntactically accepted multiple constructors', () => { + for (const source of ['val x = object :', 'val x = object : Base( {', 'val x = object : A(), B() {}']) { + expect(() => new TreeSitterExtractor('Bad.kt', source, 'kotlin').extract()).not.toThrow(); + } + }); +}); diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index 14b5198f47..1ca83df5d0 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -81,6 +81,27 @@ describe('Resolution Module', () => { }); describe('Name Matcher', () => { + it('accepts every supported supertype kind in exact-name inheritance matching', () => { + for (const kind of ['component', 'namespace'] as const) { + const target: Node = { + id: `${kind}:base`, kind, name: 'Base', qualifiedName: 'Base', + filePath: 'model.ts', language: 'typescript', startLine: 1, endLine: 1, + startColumn: 0, endColumn: 0, updatedAt: 0, + }; + const context = { + getNodesByName: () => [target], getNodesInFile: () => [target], + getNodesByQualifiedName: () => [], getNodesByKind: () => [], + fileExists: () => true, readFile: () => null, + getProjectRoot: () => tempDir, getAllFiles: () => ['model.ts'], + } as ResolutionContext; + const ref: UnresolvedRef = { + fromNodeId: 'class:derived', referenceName: 'Base', referenceKind: 'extends', + filePath: 'model.ts', language: 'typescript', line: 2, column: 0, + }; + expect(matchByExactName(ref, context)?.targetNodeId).toBe(target.id); + } + }); + it('should match exact name references', () => { // Create a mock context const mockNodes: Node[] = [ @@ -6047,6 +6068,54 @@ in }); }); + describe('A dotted qualified extends/implements reference resolves to a real nested type (2026-09-11)', () => { + it('resolves `extends Outer.Inner` (named class) and `new Outer.Inner() { ... }` (anonymous class) to the SAME real, indexed nested type', async () => { + // Every qualifiedName the engine builds joins scope with `::` + // (buildQualifiedName), but a Java/C# extends clause or anonymous-class + // constructor type is recorded verbatim with a dot (`Outer.Inner`). A + // real in-project nested type must still resolve — this is not an + // AOSP-specific concern (an absent AIDL Stub staying unresolved is + // correct there), it's the general case where the target genuinely + // exists in the index. + fs.writeFileSync( + path.join(tempDir, 'Host.java'), + `package p; +class Outer { static class Inner { public void run() {} } } +class Named extends Outer.Inner {} +class Host { + Object field = new Outer.Inner() { public void run() {} }; +} +` + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + const db = DatabaseConnection.open(path.join(tempDir, '.codegraph', 'codegraph.db')); + const innerId = db + .getDb() + .prepare("select id from nodes where kind = 'class' and name = 'Inner'") + .get() as { id: string } | undefined; + expect(innerId, 'the real Outer.Inner class should be indexed').toBeDefined(); + + const extendsTargets = db + .getDb() + .prepare( + `select src.name as sourceName, dst.id as targetId + from edges e + join nodes src on src.id = e.source + join nodes dst on dst.id = e.target + where e.kind = 'extends' and dst.id = ?` + ) + .all(innerId!.id) as Array<{ sourceName: string; targetId: string }>; + + const sourceNames = extendsTargets.map((r) => r.sourceName).sort(); + // `Named` (a real named class) and the anonymous class inside `Host` + // (named ``) must BOTH resolve their extends edge to + // the real `Inner` node — neither should stay in unresolved_refs. + expect(sourceNames.some((n) => n === 'Named')).toBe(true); + expect(sourceNames.some((n) => /Inner\$anon@/.test(n))).toBe(true); + }); + }); + describe('Bindings in a module that exports nothing (#1719)', () => { it('does not treat documentation headings as package imports', () => { // Inject the planned Markdown node shape without depending on its extractor. @@ -6311,4 +6380,75 @@ bracketed() expect(reachedFrom('consumer.ts', 'HiddenFace')).toBe(false); }, 30000); }); + + describe('Inheritance references never use method-call resolution', () => { + it('does not resolve bare extends/implements names to same-named methods', async () => { + fs.writeFileSync( + path.join(tempDir, 'Hierarchy.java'), + `class Child extends Missing {} +class Implementer implements Missing {} +class Other { + void Missing() {} +} +` + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + const db = DatabaseConnection.open(path.join(tempDir, '.codegraph', 'codegraph.db')); + const rows = db + .getDb() + .prepare( + `select src.name as sourceName, dst.kind as targetKind + from edges e + join nodes src on src.id = e.source + join nodes dst on dst.id = e.target + where e.kind in ('extends', 'implements') + and src.name in ('Child', 'Implementer')` + ) + .all() as Array<{ sourceName: string; targetKind: string }>; + + expect(rows.filter((row) => row.targetKind === 'method')).toEqual([]); + }); + + it('does not resolve Java extends IBar.Stub to an unrelated Stub constructor', async () => { + // IBar intentionally has no in-project declaration: this mirrors generated + // AIDL Stub bases that are absent from a sparse source checkout. An explicit + // constructor is required because implicit Java constructors are not nodes. + fs.writeFileSync( + path.join(tempDir, 'AService.java'), + `package com.example; +class AService { + final class BinderService extends IBar.Stub {} +} +` + ); + fs.writeFileSync( + path.join(tempDir, 'UiModeManagerService.java'), + `package com.example; +class UiModeManagerService { + class Stub { Stub() {} } +} +` + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + const db = DatabaseConnection.open(path.join(tempDir, '.codegraph', 'codegraph.db')); + const rows = db + .getDb() + .prepare( + `select dst.kind as targetKind, dst.qualified_name as targetQualifiedName, + e.metadata as metadata + from edges e + join nodes src on src.id = e.source + join nodes dst on dst.id = e.target + where e.kind = 'extends' and src.name = 'BinderService'` + ) + .all() as Array<{ targetKind: string; targetQualifiedName: string; metadata: string }>; + + // An inheritance reference is a type reference, never a receiver.method() + // call. In particular, an absent IBar must not make `Stub` fall through to + // the sole same-named constructor elsewhere in the project. + expect(rows.filter((row) => row.targetKind === 'method')).toEqual([]); + }); + }); }); diff --git a/codegraph-kernel/src/java.rs b/codegraph-kernel/src/java.rs index daaf49de0e..8b2ff755bd 100644 --- a/codegraph-kernel/src/java.rs +++ b/codegraph-kernel/src/java.rs @@ -944,13 +944,28 @@ impl<'t> Walker<'t> { .or_else(|| node.child_by_field_name("type")) .or_else(|| node.child_by_field_name("name")) .or_else(|| node.named_child(0)); - let mut type_name = type_node.map(|t| self.text(t).to_string()).unwrap_or_else(|| "Object".to_string()); - type_name = strip_generic_and_qualifier(&type_name); - if type_name.is_empty() { - type_name = "Object".to_string(); + let raw_type_name = type_node.map(|t| self.text(t).to_string()).unwrap_or_else(|| "Object".to_string()); + // The `extends` reference must carry the FULL dotted name (generics + // stripped, qualifier kept) — AOSP-style qualified-name lookups + // (hal.ts/aidl.ts's `IFoo` / `IFoo.%` prefix match) depend on it + // surviving. Only the anon class's own cosmetic name is truncated to + // the bare last segment, matching the portable extractor. + let full_type_name = { + let mut n = raw_type_name.clone(); + if let Some(lt) = n.find('<') { + if lt > 0 { + n.truncate(lt); + } + } + let trimmed = n.trim().to_string(); + if trimmed.is_empty() { "Object".to_string() } else { trimmed } + }; + let mut short_type_name = strip_generic_and_qualifier(&raw_type_name); + if short_type_name.is_empty() { + short_type_name = "Object".to_string(); } - let anon_name = format!("<{type_name}$anon@{}>", node.start_position().row + 1); + let anon_name = format!("<{short_type_name}$anon@{}>", node.start_position().row + 1); let Some(row) = self.create_node("class", &anon_name, node, Extra::default()) else { return; }; @@ -960,7 +975,7 @@ impl<'t> Walker<'t> { Some(t) => (t.start_position().row as u32, self.col_of(t)), None => (node.start_position().row as u32, self.col_of(node)), }; - self.push_ref(row, &type_name, edge_kind_index("extends").unwrap(), line, column); + self.push_ref(row, &full_type_name, edge_kind_index("extends").unwrap(), line, column); self.stack.push(Scope { row, kind: "class", name: anon_name }); for i in 0..body.named_child_count() { diff --git a/codegraph-kernel/src/kotlin.rs b/codegraph-kernel/src/kotlin.rs index 8526b098c4..7aa6b3cab0 100644 --- a/codegraph-kernel/src/kotlin.rs +++ b/codegraph-kernel/src/kotlin.rs @@ -224,6 +224,11 @@ pub struct Walker<'t> { fs_values: HashMap, fs_value_counts: HashMap, value_scopes: Vec>, + /// kotlinObjectScopeDepth (tree-sitter.ts:480) — >0 while inside an + /// `object : IFoo.Stub() { ... }` literal body, so createNode binds + /// member identity to the enclosing owner (two anon objects can share + /// both name and line). + kotlin_object_scope_depth: u32, } pub fn extract(file_path: &str, source: &str) -> Result { @@ -258,6 +263,7 @@ pub fn extract(file_path: &str, source: &str) -> Result { fs_values: HashMap::new(), fs_value_counts: HashMap::new(), value_scopes: Vec::new(), + kotlin_object_scope_depth: 0, }; let line_count = source.bytes().filter(|b| *b == b'\n').count() as u32 + 1; @@ -407,8 +413,14 @@ impl<'t> Walker<'t> { return None; } let start_line = self.line_of(node); + // Members in different anonymous objects can share a name and line. + let identity_name = if self.kotlin_object_scope_depth > 0 && !self.stack.is_empty() { + format!("{}::{}", self.node_ids[self.top_row() as usize], name) + } else { + name.to_string() + }; let column = self.col_of(node); - let id = self.node_id_allocator.generate(self.file_path, kind, name, start_line, column); + let id = self.node_id_allocator.generate(self.file_path, kind, &identity_name, start_line, column); // endLine extension via resolveBody — LIVE for kotlin function/method // kinds (in-range for this grammar, so practically a no-op — but the // hook is part of the contract). @@ -886,10 +898,15 @@ impl<'t> Walker<'t> { self.extract_call(node); } else if kind == "infix_expression" { self.extract_infix_call(node); + } else if kind == "object_literal" { + // Kotlin `object : IFoo.Stub() { ... }` — AIDL Stub implementation + // idiom, matching the portable extractor. + self.extract_kotlin_object_literal(node); + skip_children = true; } // companion_object, anonymous_initializer, secondary_constructor, - // getter/setter siblings, file_annotation, object_literal, if/when at - // top level: no branch — recursed (calls attribute to the stack top). + // getter/setter siblings, file_annotation, if/when at top level: no + // branch — recursed (calls attribute to the stack top). if !skip_children { for i in 0..node.named_child_count() { @@ -954,6 +971,10 @@ impl<'t> Walker<'t> { // object_declaration is NOT dispatched here — a body-local object's // `fun`s hit the function branch above and leak out as FUNCTIONS // under the enclosing fn; its properties mint nothing (quirk). + if kind == "object_literal" { + self.extract_kotlin_object_literal(node); + return; + } for i in 0..node.named_child_count() { if let Some(c) = node.named_child(i) { @@ -1393,6 +1414,135 @@ impl<'t> Walker<'t> { } } + /// extractKotlinObjectLiteral (tree-sitter.ts:5330) — `object : IFoo.Stub() { ... }`, + /// the dominant AIDL Stub implementation idiom. NOT the same AST shape as + /// Java/C#'s object_creation_expression (repeatable delegation_specifier + /// supertypes, no single constructor/type field) — its own extraction. + fn extract_kotlin_object_literal(&mut self, node: Node<'t>) { + stack_guard!(); + let Some(body) = (0..node.named_child_count()) + .filter_map(|i| node.named_child(i)) + .find(|c| c.kind() == "class_body") + else { + return; + }; + + let delegation_specifiers: Vec> = (0..node.named_child_count()) + .filter_map(|i| node.named_child(i)) + .filter(|c| c.kind() == "delegation_specifier") + .collect(); + + // A specifier contains a constructor invocation, an explicit `by` + // delegation, or a bare interface type. Only the first constructs a + // supertype; a call in a delegate expression does not. Preserve all + // dotted name segments for qualified lookup (excludes type_arguments). + struct SuperType<'t> { + full_name: String, + user_type: Node<'t>, + has_call: bool, + } + let mut super_types: Vec> = Vec::new(); + for &spec in &delegation_specifiers { + let mut user_type: Option> = None; + let mut has_call = false; + for i in 0..spec.named_child_count() { + let Some(child) = spec.named_child(i) else { continue }; + if matches!(child.kind(), "constructor_invocation" | "explicit_delegation") { + has_call = child.kind() == "constructor_invocation"; + for j in 0..child.named_child_count() { + if let Some(grandchild) = child.named_child(j) { + if grandchild.kind() == "user_type" { + user_type = Some(grandchild); + break; + } + } + } + break; + } + if child.kind() == "user_type" { + user_type = Some(child); + break; + } + } + let Some(user_type) = user_type else { continue }; + // Read only direct name segments: Outer.Inner is Outer.Inner, + // and types nested in type_arguments are never supertypes themselves. + let full_name = (0..user_type.named_child_count()) + .filter_map(|i| user_type.named_child(i)) + .filter(|c| c.kind() == "type_identifier") + .map(|c| self.text(c).trim().to_string()) + .collect::>() + .join("."); + if !full_name.is_empty() { + super_types.push(SuperType { full_name, user_type, has_call }); + } + } + // Header expressions execute in the enclosing scope, not as members of + // the new class. Visit each spec once so constructor arguments, + // delegates, and objects nested in either retain their calls/structure. + for &spec in &delegation_specifiers { + self.visit_node(spec); + } + + let enclosing_row = if self.stack.is_empty() { None } else { Some(self.top_row()) }; + if let Some(enclosing_row) = enclosing_row { + if let Some(primary_ctor) = super_types.iter().find(|s| s.has_call) { + self.push_ref( + enclosing_row, + &primary_ctor.full_name, + edge_kind_index("instantiates").unwrap(), + node.start_position().row as u32 + 1, + self.col_of(node), + ); + } + } + + // The anon class's own (cosmetic) name uses the first supertype's bare + // last segment, matching extractAnonymousClass's convention. + let mut type_name = super_types.first().map(|s| s.full_name.clone()).unwrap_or_else(|| "Object".to_string()); + if let Some(last_dot) = type_name.rfind('.') { + type_name = type_name[(last_dot + 1)..].to_string(); + } + type_name = type_name.trim().to_string(); + if type_name.is_empty() { + type_name = "Object".to_string(); + } + + // createNode IDs use name + line; include the column to distinguish + // same-type literals nested or adjacent on the same source line. + let anon_name = format!( + "<{type_name}$anon@{}:{}>", + node.start_position().row + 1, + node.start_position().column + ); + let Some(row) = self.create_node("class", &anon_name, node, Extra::default()) else { + return; + }; + + // Bug-for-bug: the TS code uses `startPosition.row` (0-based) as the + // LINE here (no +1), unlike push_ref_at's usual line_of. + let extends_kind = edge_kind_index("extends").unwrap(); + for s in &super_types { + self.push_ref( + row, + &s.full_name, + extends_kind, + s.user_type.start_position().row as u32, + self.col_of(s.user_type), + ); + } + + self.stack.push(Scope { row, kind: "class", name: anon_name }); + self.kotlin_object_scope_depth += 1; + for i in 0..body.named_child_count() { + if let Some(c) = body.named_child(i) { + self.visit_node(c); + } + } + self.kotlin_object_scope_depth -= 1; + self.stack.pop(); + } + /// extractDecoratorsFor — kotlin annotations inside `modifiers`: /// `@Marker` (user_type child) → decorates ref; `@Anno(args)` /// (constructor_invocation) → NOTHING. Runs for functions/methods/classes diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 740d9dcbea..ed9e005c69 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -550,6 +550,7 @@ export class TreeSitterExtractor { // point (this instance is the wasm fallback for a kernel-deferred file) — // don't blank it a second time. private sourceIsPreParsed = false; + private kotlinObjectScopeDepth = 0; constructor( filePath: string, @@ -1477,6 +1478,14 @@ export class TreeSitterExtractor { skipChildren = true; } } + // Kotlin `object : IFoo.Stub() { ... }` — structurally distinct from + // INSTANTIATION_KINDS (multiple repeatable `delegation_specifier` + // supertypes, no single constructor/type field), so it gets its own + // dedicated extraction rather than being folded into extractInstantiation. + else if (nodeType === 'object_literal') { + this.extractKotlinObjectLiteral(node); + skipChildren = true; + } // (Decorator handling lives inside the symbol-creating extractors // — extractClass / extractFunction / extractProperty — because the // decorator node sits BEFORE the symbol in the AST and the walker @@ -1522,7 +1531,12 @@ export class TreeSitterExtractor { return null; } - const id = this.nodeIds.generate(this.filePath, kind, name, node.startPosition.row + 1, node.startPosition.column); + // Members in different Kotlin anonymous objects can share both name and + // line. Bind their identity to the enclosing owner as well. + const identityName = this.kotlinObjectScopeDepth > 0 + ? `${this.nodeStack[this.nodeStack.length - 1]}::${name}` + : name; + const id = this.nodeIds.generate(this.filePath, kind, identityName, node.startPosition.row + 1, node.startPosition.column); // Some grammars (e.g. Dart) model a function/method body as a *sibling* of // the signature node, so the declaration node's own range is just the @@ -5512,16 +5526,22 @@ export class TreeSitterExtractor { if (!this.extractor) return; // The instantiated type sits in the same field/position that - // extractInstantiation reads from. Use the same lookup so the anon - // class's `extends` target matches the `instantiates` edge. + // extractInstantiation reads from. const typeNode = getChildByField(node, 'constructor') || getChildByField(node, 'type') || getChildByField(node, 'name') || node.namedChild(0); - let typeName = typeNode ? getNodeText(typeNode, this.source) : 'Object'; - const ltIdx = typeName.indexOf('<'); - if (ltIdx > 0) typeName = typeName.slice(0, ltIdx); + let fullTypeName = typeNode ? getNodeText(typeNode, this.source) : 'Object'; + const ltIdx = fullTypeName.indexOf('<'); + if (ltIdx > 0) fullTypeName = fullTypeName.slice(0, ltIdx); + fullTypeName = fullTypeName.trim() || 'Object'; + + // The anon class's own (cosmetic) name is deliberately the short, bare + // form — matching extractInstantiation's `instantiates` truncation, since + // that edge resolves by bare class name (a real in-project nested type's + // own node is named by its last segment too). + let typeName = fullTypeName; const lastDot = Math.max(typeName.lastIndexOf('.'), typeName.lastIndexOf('::')); if (lastDot >= 0) typeName = typeName.slice(lastDot + 1).replace(/^[:.]/, ''); typeName = typeName.trim() || 'Object'; @@ -5530,14 +5550,23 @@ export class TreeSitterExtractor { const classNode = this.createNode('class', anonName, node, {}); if (!classNode) return; - // The anonymous class implicitly extends/implements the named type. + // The anonymous class implicitly extends/implements the named type. The + // `extends` reference itself must NOT be truncated to the bare last + // segment the way the anon class's own name and the `instantiates` edge + // are: a NAMED class's real `extends IFoo.Stub` clause is extracted + // verbatim (untruncated) precisely because AOSP-style qualified-name + // lookups (e.g. hal.ts/aidl.ts's `IFoo` / `IFoo.%` prefix match) depend on + // the full dotted text surviving. Truncating this to "Stub" made a real + // AIDL `new ICarPropertyEventListener.Stub() { ... }` field initializer + // invisible to aidl-impl even after the anon class body itself started + // being extracted. // We can't tell at extraction time whether T is a class or an interface, // so emit `extends`. Resolution will still bind T to whatever it is, and // Phase 5.5 (which already handles both `extends` and `implements`) will // bridge T's methods to the override names found in the anon body. this.unresolvedReferences.push({ fromNodeId: classNode.id, - referenceName: typeName, + referenceName: fullTypeName, referenceKind: 'extends', line: typeNode?.startPosition.row ?? node.startPosition.row, column: typeNode?.startPosition.column ?? node.startPosition.column, @@ -5553,6 +5582,129 @@ export class TreeSitterExtractor { this.nodeStack.pop(); } + /** + * Extract a Kotlin anonymous object expression — `object : IFoo.Stub() { ... }` + * — a common AIDL Stub implementation idiom. This has a different AST shape from Java/C#'s + * `object_creation_expression`, so it cannot reuse `extractAnonymousClass`: + * + * object_literal + * delegation_specifier (one per supertype, repeatable — + * constructor_invocation Kotlin allows `object : Base(), I1, I2 { }`) + * user_type + * type_identifier ... (dotted segments, e.g. IFoo, Stub) + * -- OR, for an interface with no constructor call -- + * user_type + * type_identifier ... + * class_body + * + * Before this function existed, `object_literal` was not in + * INSTANTIATION_KINDS and had no anonymous-class handling at all, so this + * idiom produced neither an `instantiates` nor an `extends` reference — + * the interface→impl synthesizer (Phase 5.5) and aidl.ts's unresolved_refs + * lookup never saw these implementations. + */ + private extractKotlinObjectLiteral(node: SyntaxNode): void { + if (!this.extractor) return; + const body = this.findAnonymousClassBody(node); + if (!body) return; + + const delegationSpecifiers: SyntaxNode[] = []; + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child && child.type === 'delegation_specifier') delegationSpecifiers.push(child); + } + + // A specifier contains a constructor invocation, an explicit `by` + // delegation, or a bare interface type. Only the first constructs a + // supertype; a call in a delegate expression does not. Preserve all + // dotted name segments for qualified lookup, excluding type arguments. + const superTypes: { fullName: string; userType: SyntaxNode; hasCall: boolean }[] = []; + for (const spec of delegationSpecifiers) { + let userType: SyntaxNode | null = null; + let hasCall = false; + for (let i = 0; i < spec.namedChildCount; i++) { + const child = spec.namedChild(i); + if (!child) continue; + if (child.type === 'constructor_invocation' || child.type === 'explicit_delegation') { + hasCall = child.type === 'constructor_invocation'; + for (let j = 0; j < child.namedChildCount; j++) { + const grandchild = child.namedChild(j); + if (grandchild && grandchild.type === 'user_type') { + userType = grandchild; + break; + } + } + break; + } + if (child.type === 'user_type') { + userType = child; + break; + } + } + if (!userType) continue; + // Read only direct name segments: Outer.Inner is Outer.Inner, + // and types nested in type_arguments are never supertypes themselves. + const fullName = userType.namedChildren + .filter((child) => child.type === 'type_identifier') + .map((child) => getNodeText(child, this.source).trim()) + .join('.'); + if (fullName) superTypes.push({ fullName, userType, hasCall }); + } + // Header expressions execute in the enclosing scope, not as members of + // the new class. Visit each spec once so constructor arguments, delegates, + // and objects nested in either retain their calls and structure. + for (const spec of delegationSpecifiers) this.visitNode(spec); + + if (this.nodeStack.length > 0) { + const fromId = this.nodeStack[this.nodeStack.length - 1]; + const primaryCtor = superTypes.find((s) => s.hasCall); + if (fromId && primaryCtor) { + this.unresolvedReferences.push({ + fromNodeId: fromId, + referenceName: primaryCtor.fullName, + referenceKind: 'instantiates', + line: node.startPosition.row + 1, + column: node.startPosition.column, + }); + } + } + + // The anon class's own (cosmetic) name uses the first supertype's bare + // last segment, matching extractAnonymousClass's convention. + let typeName = superTypes[0]?.fullName ?? 'Object'; + const lastDot = typeName.lastIndexOf('.'); + if (lastDot >= 0) typeName = typeName.slice(lastDot + 1); + typeName = typeName.trim() || 'Object'; + + // createNode IDs use name + line; include the column to distinguish + // same-type literals nested or adjacent on the same source line. + const anonName = `<${typeName}$anon@${node.startPosition.row + 1}:${node.startPosition.column}>`; + const classNode = this.createNode('class', anonName, node, {}); + if (!classNode) return; + + for (const { fullName, userType } of superTypes) { + this.unresolvedReferences.push({ + fromNodeId: classNode.id, + referenceName: fullName, + referenceKind: 'extends', + line: userType.startPosition.row, + column: userType.startPosition.column, + }); + } + + this.nodeStack.push(classNode.id); + this.kotlinObjectScopeDepth++; + try { + for (let i = 0; i < body.namedChildCount; i++) { + const child = body.namedChild(i); + if (child) this.visitNode(child); + } + } finally { + this.kotlinObjectScopeDepth--; + this.nodeStack.pop(); + } + } + /** * Scan `declNode` and its preceding siblings (within the parent's * named children) for decorator nodes, emitting a `decorates` @@ -5997,6 +6149,11 @@ export class TreeSitterExtractor { } } else if (this.language === 'kotlin' && nodeType === 'infix_expression') { this.extractKotlinInfixCall(node); + } else if (nodeType === 'object_literal') { + // Kotlin `object : IFoo.Stub() { ... }` inside a function body — + // same rationale and structure as the visitNode branch above. + this.extractKotlinObjectLiteral(node); + return; } else if (this.extractor!.extractBareCall) { const calleeName = this.extractor!.extractBareCall(node, this.source); if (calleeName && this.nodeStack.length > 0) { diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index a22550979c..5af596f7c4 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -5577,6 +5577,26 @@ export function matchByExactName( return null; } +/** + * Whether `qualifiedName` ends with `suffix` at a real `::` scope boundary — + * NOT a plain `String.endsWith`, which false-matches across an identifier + * boundary purely by character coincidence: `"Aaa::operator+"` ends with the + * literal substring `"a::operator+"` only because `Aaa` itself happens to + * end in the letter `a`, even though `"a"` there was never meant as a scope + * qualifier at all — it was a C++ receiver *variable* named `a` in a + * `a.operator+(b)` call, decoy-matching the unrelated `Aaa::operator+` + * method instead of leaving receiver-type inference (`matchMethodCall`) to + * find the real `V::operator+`. A match only counts when the + * suffix is the WHOLE qualifiedName, or the two characters immediately + * preceding it are a real `::` separator. + */ +function endsWithQualifiedSegment(qualifiedName: string, suffix: string): boolean { + if (qualifiedName === suffix) return true; + if (!qualifiedName.endsWith(suffix)) return false; + const boundary = qualifiedName.length - suffix.length; + return boundary >= 2 && qualifiedName[boundary - 1] === ':' && qualifiedName[boundary - 2] === ':'; +} + /** * Try to resolve by qualified name */ @@ -5596,14 +5616,31 @@ export function matchByQualifiedName( // must never resolve to a yaml/properties config node — that's a wrong edge // AND it hides the real callee. Drop those from both the exact and the partial // candidate sets so resolution falls through to method resolution below (#1180). - const keepForRef = (nodes: Node[]): Node[] => - ref.referenceKind === 'calls' - ? nodes.filter( - (n) => !(n.kind === 'constant' && (n.language === 'yaml' || n.language === 'properties')), - ) - : nodes; + const keepForRef = (nodes: Node[]): Node[] => { + let kept = nodes; + if (ref.referenceKind === 'calls') { + kept = kept.filter( + (n) => !(n.kind === 'constant' && (n.language === 'yaml' || n.language === 'properties')), + ); + } + return isInheritanceRef(ref) ? kept.filter(isSupertypeTarget) : kept; + }; - let candidates = keepForRef(context.getNodesByQualifiedName(ref.referenceName)); + // The GENERIC class-hierarchy walker's qualifiedName always joins scope + // with `::` (buildQualifiedName in tree-sitter.ts) regardless of source + // language, but an extends/implements clause's own text is recorded + // verbatim from the language's own syntax — so a Java/C# dotted supertype + // (`Outer.Inner`, `IFoo.Stub`) never matched a real indexed type's + // `::`-joined qualifiedName without normalizing the separator first. + // Scoped to ONLY inheritance references: several non-generic extractors + // (MyBatis XML statements, for one) deliberately build a MIXED + // qualifiedName that keeps literal dots from an already-dotted Java + // package/namespace string and adds `::` only at one specific boundary — + // blanket-normalizing every reference's dots to `::` mangled those. + const normalizedQualifiedRef = isInheritanceRef(ref) + ? ref.referenceName.replace(/\./g, '::') + : ref.referenceName; + let candidates = keepForRef(context.getNodesByQualifiedName(normalizedQualifiedRef)); // A C# `using X.Y;` names a namespace: one the project declares, else it is // the file's own (external) using — never another file's using of that name. if (ref.language === 'csharp' && ref.referenceKind === 'imports') { @@ -5671,8 +5708,18 @@ export function matchByQualifiedName( const parts = ref.referenceName.split(/[:.]/); const lastName = parts[parts.length - 1]; if (lastName) { + // The strict `::`-boundary check only matters where normalization + // actually ran (inheritance refs) — every other reference kind keeps + // the original, deliberately loose `endsWith` (e.g. Expo's JS call site + // binds a shortened alias like `Haptics` that only matches the END of + // the native module's `...::ExpoHaptics.method` qualifiedName by design, + // with no `::` immediately before it). const partialCandidates = keepForRef(context.getNodesByName(lastName)) - .filter((candidate) => candidate.qualifiedName.endsWith(ref.referenceName)); + .filter((candidate) => + isInheritanceRef(ref) + ? endsWithQualifiedSegment(candidate.qualifiedName, normalizedQualifiedRef) + : candidate.qualifiedName.endsWith(normalizedQualifiedRef) + ); const chosen = preferCallSiteFile(partialCandidates, ref.filePath)[0]; if (chosen) { return { @@ -5963,6 +6010,11 @@ export function resolveMethodOnType( /** Recursion guard for the supertype/conformance walk. */ depth = 0, ): ResolvedRef | null { + // This helper resolves a receiver's invoked member, never a type/member + // reference such as Java `class Foo extends IBar.Stub`. Keep the guard here + // as a backstop for every current and future caller of this call-only API. + if (ref.referenceKind !== 'calls') return null; + // Look up methods by name and match by qualifiedName ending in // `::`. This works whether the method is defined // in-class (`class Foo { int bar() { ... } }`) or out-of-line in a separate @@ -6286,6 +6338,7 @@ export function matchCppCallChain( ref: UnresolvedRef, context: ResolutionContext, ): ResolvedRef | null { + if (ref.referenceKind !== 'calls') return null; const m = ref.referenceName.match(/^(.+)\(\)\.(\w+)$/); if (!m || !m[1] || !m[2]) return null; const cls = resolveCppCallResultType(m[1], ref, context); @@ -6308,6 +6361,7 @@ export function matchScopedCallChain( ref: UnresolvedRef, context: ResolutionContext, ): ResolvedRef | null { + if (ref.referenceKind !== 'calls') return null; const m = ref.referenceName.match(/^(.+)\(\)\.(\w+)$/); if (!m || !m[1] || !m[2]) return null; const inner = m[1]; @@ -6347,6 +6401,7 @@ export function matchDottedCallChain( ref: UnresolvedRef, context: ResolutionContext, ): ResolvedRef | null { + if (ref.referenceKind !== 'calls') return null; const m = ref.referenceName.match(/^(.+)\(\)\.(\w+)$/); if (!m || !m[1] || !m[2]) return null; const inner = m[1]; // `Foo.getInstance` @@ -8144,6 +8199,12 @@ export function matchMethodCall( ref: UnresolvedRef, context: ResolutionContext ): ResolvedRef | null { + // A dotted/scoped name is not necessarily an invocation: Java inheritance + // refs such as `IBar.Stub` have the same surface shape. Let type-reference + // strategies handle non-call refs rather than falling through to the + // same-named-method heuristics below. + if (ref.referenceKind !== 'calls') return null; + // Parse method call patterns like "obj.method" or "Class::method". The method // part allows trailing `:` keywords so Objective-C selectors resolve // (`SDImageCache.storeImage:`, `obj.setX:y:`); colons never appear in other @@ -9803,7 +9864,7 @@ export function matchFuzzy( // lowercase index is for. const bareR = isBareRCall(ref, context); const solidityBare = isReceiverLessSolidityCall(ref, context); - const callableCandidates = candidates.filter((n) => callableKinds.has(n.kind) && !(typeRef && !canNameInTypePosition(n)) && + const callableCandidates = candidates.filter((n) => (isInheritanceRef(ref) ? isSupertypeTarget(n) : callableKinds.has(n.kind)) && !(typeRef && !canNameInTypePosition(n)) && !(bareR && n.kind === 'method') && !(solidityBare && !isSolidityMemberInScope(n, ref, context)) && // `new …MockData()` makes an instance of a type; a method is never what it names. From ce175f97974e321cccd715c17507e863e67caa2c Mon Sep 17 00:00:00 2001 From: mixxer Date: Thu, 24 Sep 2026 13:21:40 +0900 Subject: [PATCH 09/15] Disambiguate Java nested supertypes across source trees --- __tests__/resolution.test.ts | 38 ++++++++++++++++++++++++++++++++++ src/resolution/name-matcher.ts | 11 +++++++++- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index 1ca83df5d0..41eb202243 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -6450,5 +6450,43 @@ class UiModeManagerService { // the sole same-named constructor elsewhere in the project. expect(rows.filter((row) => row.targetKind === 'method')).toEqual([]); }); + + it('resolves a nested Java supertype to the closest source tree', async () => { + for (const tree of ['framework', 'androidx']) { + const dir = path.join(tempDir, tree); + fs.mkdirSync(dir); + fs.writeFileSync(path.join(dir, 'RecyclerView.java'), + `package ${tree}; class RecyclerView { static class LayoutManager {} }`); + } + fs.writeFileSync(path.join(tempDir, 'androidx', 'LinearLayoutManager.java'), + 'package androidx; class LinearLayoutManager extends RecyclerView.LayoutManager {}'); + + cg = await CodeGraph.init(tempDir, { index: true }); + const db = DatabaseConnection.open(path.join(tempDir, '.codegraph', 'codegraph.db')); + const rows = db.getDb().prepare( + `select dst.file_path as targetPath from edges e + join nodes src on src.id = e.source + join nodes dst on dst.id = e.target + where e.kind = 'extends' and src.name = 'LinearLayoutManager'` + ).all() as Array<{ targetPath: string }>; + expect(rows.map((row) => row.targetPath)).toEqual(['androidx/RecyclerView.java']); + }); + + it('does not let Spring naming conventions invent an inheritance edge', async () => { + fs.writeFileSync(path.join(tempDir, 'Service.java'), + '@Service class Service {} class Child implements View.OnClickListener {}'); + fs.writeFileSync(path.join(tempDir, 'OnClickListener.java'), + 'package unrelated; class OnClickListener {}'); + + cg = await CodeGraph.init(tempDir, { index: true }); + const db = DatabaseConnection.open(path.join(tempDir, '.codegraph', 'codegraph.db')); + const rows = db.getDb().prepare( + `select dst.qualified_name as target from edges e + join nodes src on src.id = e.source + join nodes dst on dst.id = e.target + where e.kind = 'implements' and src.name = 'Child'` + ).all() as Array<{ target: string }>; + expect(rows).toEqual([]); + }); }); }); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 5af596f7c4..b25570d300 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -5720,7 +5720,16 @@ export function matchByQualifiedName( ? endsWithQualifiedSegment(candidate.qualifiedName, normalizedQualifiedRef) : candidate.qualifiedName.endsWith(normalizedQualifiedRef) ); - const chosen = preferCallSiteFile(partialCandidates, ref.filePath)[0]; + // `RecyclerView.LayoutManager` can exist in both AndroidX and the + // framework. For inheritance, pick the uniquely closest source tree; + // an equal-distance tie has no safe target. + const closest = isInheritanceRef(ref) && partialCandidates.length > 1 + ? partialCandidates.map((node) => ({ node, distance: computePathProximity(ref.filePath, node.filePath) })) + .sort((a, b) => b.distance - a.distance) + : null; + const chosen = closest + ? (closest[0]!.distance > closest[1]!.distance ? closest[0]!.node : null) + : preferCallSiteFile(partialCandidates, ref.filePath)[0]; if (chosen) { return { original: ref, From 6a2a0ee59f3aa6f36161085d00e60631ca7a7911 Mon Sep 17 00:00:00 2001 From: mixxer Date: Thu, 24 Sep 2026 16:54:55 +0900 Subject: [PATCH 10/15] docs: keep Java and Kotlin extraction notes self-contained --- __tests__/extraction.test.ts | 10 +++------- __tests__/resolution.test.ts | 2 +- codegraph-kernel/src/java.rs | 5 ++--- src/extraction/tree-sitter.ts | 11 +++-------- 4 files changed, 9 insertions(+), 19 deletions(-) diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index 97f2badfd6..f10a3d048b 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -1924,13 +1924,9 @@ class T { // `instantiates` resolves (by bare class name). But the `extends` // reference is a DIFFERENT resolution path: a real named class's // `extends IFoo.Stub` clause is extracted verbatim (untruncated), and - // AOSP-style qualified lookups (hal.ts/aidl.ts's `IFoo`/`IFoo.%` prefix - // match) depend on that full dotted text surviving. Truncating the - // anon-class extends reference to "Stub" the same way made a real field - // initializer like `new ICarPropertyEventListener.Stub() { ... }` - // invisible to `aidl-impl` even after the anon body itself was extracted - // (found on the real platform_packages_services_car AOSP mirror, - // 2026-09-11). + // qualified lookups depend on that full dotted text surviving. + // Truncating the anonymous class's extends reference to "Stub" would + // lose the relationship to `ICarPropertyEventListener.Stub`. const code = ` package p; interface ICarPropertyEventListener { diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index 41eb202243..c33a6dfbab 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -6068,7 +6068,7 @@ in }); }); - describe('A dotted qualified extends/implements reference resolves to a real nested type (2026-09-11)', () => { + describe('A dotted qualified extends/implements reference resolves to a real nested type', () => { it('resolves `extends Outer.Inner` (named class) and `new Outer.Inner() { ... }` (anonymous class) to the SAME real, indexed nested type', async () => { // Every qualifiedName the engine builds joins scope with `::` // (buildQualifiedName), but a Java/C# extends clause or anonymous-class diff --git a/codegraph-kernel/src/java.rs b/codegraph-kernel/src/java.rs index 8b2ff755bd..7f4822129a 100644 --- a/codegraph-kernel/src/java.rs +++ b/codegraph-kernel/src/java.rs @@ -946,9 +946,8 @@ impl<'t> Walker<'t> { .or_else(|| node.named_child(0)); let raw_type_name = type_node.map(|t| self.text(t).to_string()).unwrap_or_else(|| "Object".to_string()); // The `extends` reference must carry the FULL dotted name (generics - // stripped, qualifier kept) — AOSP-style qualified-name lookups - // (hal.ts/aidl.ts's `IFoo` / `IFoo.%` prefix match) depend on it - // surviving. Only the anon class's own cosmetic name is truncated to + // stripped, qualifier kept) so nested-type resolution can find it. + // Only the anon class's own cosmetic name is truncated to // the bare last segment, matching the portable extractor. let full_type_name = { let mut n = raw_type_name.clone(); diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index ed9e005c69..af06006509 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -5554,12 +5554,8 @@ export class TreeSitterExtractor { // `extends` reference itself must NOT be truncated to the bare last // segment the way the anon class's own name and the `instantiates` edge // are: a NAMED class's real `extends IFoo.Stub` clause is extracted - // verbatim (untruncated) precisely because AOSP-style qualified-name - // lookups (e.g. hal.ts/aidl.ts's `IFoo` / `IFoo.%` prefix match) depend on - // the full dotted text surviving. Truncating this to "Stub" made a real - // AIDL `new ICarPropertyEventListener.Stub() { ... }` field initializer - // invisible to aidl-impl even after the anon class body itself started - // being extracted. + // verbatim (untruncated) so qualified-name resolution can find the + // nested type. Truncating this to "Stub" loses the enclosing interface. // We can't tell at extraction time whether T is a class or an interface, // so emit `extends`. Resolution will still bind T to whatever it is, and // Phase 5.5 (which already handles both `extends` and `implements`) will @@ -5600,8 +5596,7 @@ export class TreeSitterExtractor { * Before this function existed, `object_literal` was not in * INSTANTIATION_KINDS and had no anonymous-class handling at all, so this * idiom produced neither an `instantiates` nor an `extends` reference — - * the interface→impl synthesizer (Phase 5.5) and aidl.ts's unresolved_refs - * lookup never saw these implementations. + * the interface→impl synthesizer cannot see these implementations. */ private extractKotlinObjectLiteral(node: SyntaxNode): void { if (!this.extractor) return; From a7af1b11ebda19982d67edeceac55826d57b79d0 Mon Sep 17 00:00:00 2001 From: mixxer Date: Thu, 24 Sep 2026 17:56:48 +0900 Subject: [PATCH 11/15] fix: scope anonymous object methods to enclosing function --- __tests__/resolution.test.ts | 27 +++++++++++++++++++++++++++ src/resolution/name-matcher.ts | 12 ++++++------ 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index c33a6dfbab..f2111c143a 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -81,6 +81,33 @@ describe('Resolution Module', () => { }); describe('Name Matcher', () => { + it('does not match a Kotlin local anonymous-object method from a sibling function', () => { + const node = (id: string, kind: Node['kind'], name: string, qualifiedName: string, filePath: string, startLine: number, endLine: number): Node => ({ + id, kind, name, qualifiedName, filePath, language: 'kotlin', + startLine, endLine, startColumn: 0, endColumn: 0, updatedAt: 0, + }); + const outer = node('outer', 'method', 'otherTest', 'ProbeTest::otherTest', 'ProbeTest.kt', 10, 20); + const anon = node('anon', 'class', '', 'ProbeTest::otherTest::', 'ProbeTest.kt', 12, 18); + const localMethod = node('local', 'method', 'probe', `${anon.qualifiedName}::probe`, 'ProbeTest.kt', 13, 17); + const interfaceMethod = node('interface', 'method', 'probe', 'Probe::probe', 'Probe.kt', 1, 2); + const nodes = [outer, anon, localMethod, interfaceMethod]; + const context = { + getNodesByName: (name: string) => nodes.filter((n) => n.name === name), + getNodesByQualifiedName: (name: string) => nodes.filter((n) => n.qualifiedName === name), + getNodesInFile: (filePath: string) => nodes.filter((n) => n.filePath === filePath), + getNodesByKind: (kind: Node['kind']) => nodes.filter((n) => n.kind === kind), + fileExists: () => true, readFile: () => null, + getProjectRoot: () => tempDir, getAllFiles: () => ['ProbeTest.kt', 'Probe.kt'], + } as ResolutionContext; + const ref: UnresolvedRef = { + fromNodeId: 'testA', referenceName: 'probe.probe', referenceKind: 'calls', + filePath: 'ProbeTest.kt', language: 'kotlin', line: 5, column: 0, + }; + + expect(matchMethodCall(ref, context)?.targetNodeId).toBe(interfaceMethod.id); + expect(matchMethodCall({ ...ref, fromNodeId: outer.id, line: 15 }, context)?.targetNodeId).toBe(localMethod.id); + }); + it('accepts every supported supertype kind in exact-name inheritance matching', () => { for (const kind of ['component', 'namespace'] as const) { const target: Node = { diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index b25570d300..e6a107f931 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -1128,15 +1128,15 @@ const NO_NESTED_FUNCTIONS = new Set(['c', 'cpp']); const LOCAL_TYPE_KINDS = new Set(['class', 'struct', 'enum', 'interface', 'trait', 'type_alias']); /** - * A function nested inside another FUNCTION is only callable from within its - * container — Python, JS/TS, and every closure language scope it lexically. + * A function nested inside another function, or an anonymous-class method + * inside a function, is only callable from within that container. * Resolving a bare name from elsewhere to a nested local fabricates an edge * scope already rules out: `join(...)` in one function must never bind to a * `join` defined inside a DIFFERENT function (#1230). A candidate whose * qualifiedName parent is a same-file function/method is kept only when the - * ref originates inside that parent's line range. Class members are - * unaffected (their parent resolves to a class-like node), as are top-level - * symbols and C++ namespace-prefixed names (the prefix has no node). + * ref originates inside that parent's line range. Ordinary class members and + * top-level symbols have no enclosing function; C++ namespace prefixes have + * no function node. */ export function isLexicallyReachable( candidate: Node, @@ -8654,7 +8654,7 @@ export function matchMethodCall( return null; } const methods = methodCandidates.filter( - (n) => n.kind === 'method' && n.name === methodName + (n) => n.kind === 'method' && n.name === methodName && isLexicallyReachable(n, ref, context) ); // Filter to same-language candidates first From 3f7f023742ea72870bc0aca814ce2453ced1771a Mon Sep 17 00:00:00 2001 From: mixxer Date: Mon, 28 Sep 2026 07:59:34 +0900 Subject: [PATCH 12/15] Drop unreachable function-reference guard after upstream call narrowing --- src/resolution/name-matcher.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index e6a107f931..67342b543c 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -6099,7 +6099,6 @@ export function resolveMethodOnType( // block, so Java/Kotlin import disambiguation — whose target is intentionally // in ANOTHER file (#314) — is unaffected: that block returns early whenever // an import FQN pins the class. - if (ref.referenceKind === 'function_ref' && matches.length !== 1) return null; const ordered = preferCallSiteFile(matches, ref.filePath); return { original: ref, From 8d24ea1fc31f7771ba8f87c91e779c585eb9565c Mon Sep 17 00:00:00 2001 From: mixxer Date: Mon, 28 Sep 2026 08:11:18 +0900 Subject: [PATCH 13/15] fix(resolution): keep captured Go method values eligible --- src/resolution/name-matcher.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 67342b543c..4766041ecf 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -6019,10 +6019,9 @@ export function resolveMethodOnType( /** Recursion guard for the supertype/conformance walk. */ depth = 0, ): ResolvedRef | null { - // This helper resolves a receiver's invoked member, never a type/member - // reference such as Java `class Foo extends IBar.Stub`. Keep the guard here - // as a backstop for every current and future caller of this call-only API. - if (ref.referenceKind !== 'calls') return null; + // Only calls and captured method values have a callable receiver. Inheritance + // references such as `class Foo extends IBar.Stub` must not reach this helper. + if (ref.referenceKind !== 'calls' && ref.referenceKind !== 'function_ref') return null; // Look up methods by name and match by qualifiedName ending in // `::`. This works whether the method is defined From 2a74536976ceffa2c6b949598151266d07281a50 Mon Sep 17 00:00:00 2001 From: mixxer Date: Mon, 5 Oct 2026 00:16:33 +0900 Subject: [PATCH 14/15] fix: adapt PR to current upstream behavior --- CHANGELOG.md | 10 ++++++---- __tests__/resolution.test.ts | 3 +++ codegraph-kernel/src/kotlin.rs | 8 +------- src/extraction/tree-sitter.ts | 7 +------ src/resolution/name-matcher.ts | 3 ++- 5 files changed, 13 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c13a5ae1b4..3ee191f621 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,12 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Fixes + +- Kotlin calls through a class property, a primary-constructor property, or a variable set from a function call now resolve on the declared type, and a call on a library type (such as `Regex` or a JDK class) no longer links to an unrelated project method with the same name. Chained receivers such as `engine.pump.drain()`, `this.engine.drain()` and `Mode.ON.next()` resolve the same way, as do properties inherited from a project base class and variables of the enclosing function used inside an anonymous `object : …`. Re-index Kotlin projects to pick this up. +- Kotlin `fun interface` declarations are indexed, and no longer hide the declaration that follows them. +- Java and Kotlin inheritance now resolves nested types more precisely, and anonymous implementations retain their members after re-indexing. + ## [1.6.2] - 2026-10-03 @@ -262,10 +268,6 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - The Claude Code prompt hook no longer runs on the task-notification messages Claude Code injects when a background agent finishes, removing a multi-second stall on every such turn. (#1832) -- Kotlin calls through a class property, a primary-constructor property, or a variable set from a function call now resolve on the declared type, and a call on a library type (such as `Regex` or a JDK class) no longer links to an unrelated project method with the same name. Chained receivers such as `engine.pump.drain()`, `this.engine.drain()` and `Mode.ON.next()` resolve the same way, as do properties inherited from a project base class and variables of the enclosing function used inside an anonymous `object : …`. Re-index Kotlin projects to pick this up. -- Kotlin `fun interface` declarations are indexed, and no longer hide the declaration that follows them. -- Java and Kotlin inheritance now resolves nested types more precisely, and anonymous implementations retain their members after re-indexing. - - Rust calls on `self` now stay with the enclosing type instead of linking to an unrelated type’s same-named method. Thanks @L4XB. (#1861) - Turning telemetry off now resets its identity and stops running processes from recording, sending, or restoring unsent data. (#1869) diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index f2111c143a..ab54162bc7 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -93,6 +93,8 @@ describe('Resolution Module', () => { const nodes = [outer, anon, localMethod, interfaceMethod]; const context = { getNodesByName: (name: string) => nodes.filter((n) => n.name === name), + getNodesByLowerName: (name: string) => nodes.filter((n) => n.name.toLowerCase() === name), + getImportMappings: () => [], getNodesByQualifiedName: (name: string) => nodes.filter((n) => n.qualifiedName === name), getNodesInFile: (filePath: string) => nodes.filter((n) => n.filePath === filePath), getNodesByKind: (kind: Node['kind']) => nodes.filter((n) => n.kind === kind), @@ -117,6 +119,7 @@ describe('Resolution Module', () => { }; const context = { getNodesByName: () => [target], getNodesInFile: () => [target], + getNodesByLowerName: () => [target], getImportMappings: () => [], getNodesByQualifiedName: () => [], getNodesByKind: () => [], fileExists: () => true, readFile: () => null, getProjectRoot: () => tempDir, getAllFiles: () => ['model.ts'], diff --git a/codegraph-kernel/src/kotlin.rs b/codegraph-kernel/src/kotlin.rs index 7aa6b3cab0..44797f2ad7 100644 --- a/codegraph-kernel/src/kotlin.rs +++ b/codegraph-kernel/src/kotlin.rs @@ -413,14 +413,8 @@ impl<'t> Walker<'t> { return None; } let start_line = self.line_of(node); - // Members in different anonymous objects can share a name and line. - let identity_name = if self.kotlin_object_scope_depth > 0 && !self.stack.is_empty() { - format!("{}::{}", self.node_ids[self.top_row() as usize], name) - } else { - name.to_string() - }; let column = self.col_of(node); - let id = self.node_id_allocator.generate(self.file_path, kind, &identity_name, start_line, column); + let id = self.node_id_allocator.generate(self.file_path, kind, name, start_line, column); // endLine extension via resolveBody — LIVE for kotlin function/method // kinds (in-range for this grammar, so practically a no-op — but the // hook is part of the contract). diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index af06006509..2a17916789 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -1531,12 +1531,7 @@ export class TreeSitterExtractor { return null; } - // Members in different Kotlin anonymous objects can share both name and - // line. Bind their identity to the enclosing owner as well. - const identityName = this.kotlinObjectScopeDepth > 0 - ? `${this.nodeStack[this.nodeStack.length - 1]}::${name}` - : name; - const id = this.nodeIds.generate(this.filePath, kind, identityName, node.startPosition.row + 1, node.startPosition.column); + const id = this.nodeIds.generate(this.filePath, kind, name, node.startPosition.row + 1, node.startPosition.column); // Some grammars (e.g. Dart) model a function/method body as a *sibling* of // the signature node, so the declaration node's own range is just the diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 4766041ecf..721ed58da7 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -6098,6 +6098,7 @@ export function resolveMethodOnType( // block, so Java/Kotlin import disambiguation — whose target is intentionally // in ANOTHER file (#314) — is unaffected: that block returns early whenever // an import FQN pins the class. + if (ref.referenceKind === 'function_ref' && matches.length !== 1) return null; const ordered = preferCallSiteFile(matches, ref.filePath); return { original: ref, @@ -8747,7 +8748,7 @@ export function matchMethodCall( // The owner type's own name — not its namespace (`eShop.ClientApp…` // shares `Client` with every `httpClient`) nor the method's. const cut = method.qualifiedName.lastIndexOf('::'); - const classWords = cut > 0 ? splitCamelCase(method.qualifiedName.slice(0, cut).split(/::|\./).pop()!) : []; + const classWords = cut > 0 ? splitCamelCase(method.qualifiedName.slice(0, cut).split(/::|\./).pop()!.replace(/^<(.+)\$anon@[^>]*>$/, '$1')) : []; let score = receiverWords.filter(w => classWords.some(cw => cw.toLowerCase() === w.toLowerCase()) ).length; From fb7ff1df904955bcdec12da26cf8638060603f69 Mon Sep 17 00:00:00 2001 From: mixxer Date: Mon, 5 Oct 2026 00:19:09 +0900 Subject: [PATCH 15/15] test: fix LSP diagnostics in PR fixtures --- __tests__/kotlin-property-receiver.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/__tests__/kotlin-property-receiver.test.ts b/__tests__/kotlin-property-receiver.test.ts index dcc6e2fac8..14106d4f8f 100644 --- a/__tests__/kotlin-property-receiver.test.ts +++ b/__tests__/kotlin-property-receiver.test.ts @@ -234,7 +234,7 @@ class Wiring { } } `); - cg = CodeGraph.initSync(dir, { config: { include: ['**/*.kt'], exclude: [] } }); + cg = CodeGraph.initSync(dir); await cg.indexAll(); });