From e294ee8a9a4785fe61da11716f3a18477c58eb17 Mon Sep 17 00:00:00 2001 From: danusha2345 Date: Thu, 24 Sep 2026 11:56:40 +0300 Subject: [PATCH 01/16] 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 17022e6343..ddfa556175 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -611,6 +611,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 6b582f6d9c..dbc5b1471d 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -17947,6 +17947,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, @@ -17967,6 +17973,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 6c089e8402c68336b8827bbca3beae51d03f91e9 Mon Sep 17 00:00:00 2001 From: danusha2345 Date: Thu, 24 Sep 2026 12:14:18 +0300 Subject: [PATCH 02/16] 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 c849830940..3e8397c54a 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 d9961ee58a2fc92ee36868e3a6781a5d9515f795 Mon Sep 17 00:00:00 2001 From: danusha2345 Date: Thu, 24 Sep 2026 12:14:18 +0300 Subject: [PATCH 03/16] 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 dbc5b1471d..82e263afb2 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -17995,23 +17995,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); @@ -18026,6 +18012,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()`) @@ -20324,6 +20458,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 // `const api = { call() {…}, get: () => {…} }`. Its members are functions // inside the constant's extent, found by containment — none of the From 7ad38b276ce74075ec00f31bc34c08460f121a8d Mon Sep 17 00:00:00 2001 From: danusha2345 Date: Thu, 24 Sep 2026 12:14:40 +0300 Subject: [PATCH 04/16] 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 ddfa556175..5bc8250447 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -611,7 +611,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 336acb5c0e0ef84c5f9e8888f3e796aedebf96f5 Mon Sep 17 00:00:00 2001 From: danusha2345 Date: Thu, 24 Sep 2026 12:28:50 +0300 Subject: [PATCH 05/16] 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 82e263afb2..1965edd208 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -17939,9 +17939,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 && @@ -18064,21 +18066,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 @@ -18109,7 +18149,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) { @@ -18140,6 +18183,7 @@ function kotlinReceiverDeclaredType( name: string, ref: UnresolvedRef, context: ResolutionContext, + depth = 0, ): string | null { const lines = context.getFileLines ? context.getFileLines(ref.filePath) @@ -18152,12 +18196,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 c9cad5cfd378ebd2d54cc0ea5f7f2572411cba32 Mon Sep 17 00:00:00 2001 From: danusha2345 Date: Thu, 24 Sep 2026 15:46:07 +0300 Subject: [PATCH 06/16] 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 5bc8250447..4b83ee26e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -611,7 +611,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 3e85d3de54..e23349a8bc 100644 --- a/__tests__/fixtures/kernel-parity/torture.kt +++ b/__tests__/fixtures/kernel-parity/torture.kt @@ -291,3 +291,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 83170723ff..fb4d4cdc52 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; @@ -1264,9 +1286,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 1cf704d437..1f9024dc85 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -459,6 +459,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']); /** Languages whose `class` node is a class expression (#2499). */ @@ -6323,6 +6325,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 45538ccc92..8d8e262fd0 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, matchObjectPathCall, thisScopeCaller, isVisibleAcrossFiles, matchReference, matchByFilePath, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesCodeBoundary, gateLanguageMatch, dumpNameMatcherProfile, clearNameMatcherMemos, isRustNameInScope, isRustForeignImplCallable, isRustPathCallReachable, rustChainedCallByName, CASE_INSENSITIVE_LANGUAGES, isDartMemberRead, matchDartMemberRead, isDartChainLink, matchDartChainLink, isDartAnnotation, matchDartAnnotation, isStdMethodName, isGoUnknownQualified, isGoProjectLocalCall, GO_STDLIB_PACKAGES, isGoBareName, goTypePositionTarget, goDotImportedSupertype, GO_TYPE_KINDS, matchGoAssertedCall, matchGoIndexedCall, matchGoChainedCall, preferGoBuildTwin, isGoAssertedLocal, isGoLocallyBound, isPythonFromImportModule, pythonAttrRefResolves, matchPythonTypedLocalCall, isImportShadowedByJsBinding, isManualMockPath, isTestSuitePath, matchNewReceiverCall, newReceiverClass, NEW_RECEIVER_SHAPE, isGuessedRecordAccessor } from './name-matcher'; +import { isPythonSelfCall, matchJsStoreBindingCall, isUnresolvedJsMemberCall, matchObjectPathCall, thisScopeCaller, isVisibleAcrossFiles, matchReference, matchByFilePath, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesCodeBoundary, gateLanguageMatch, dumpNameMatcherProfile, clearNameMatcherMemos, isRustNameInScope, isRustForeignImplCallable, isRustPathCallReachable, rustChainedCallByName, CASE_INSENSITIVE_LANGUAGES, isDartMemberRead, matchDartMemberRead, isDartChainLink, matchDartChainLink, isDartAnnotation, matchDartAnnotation, isStdMethodName, isGoUnknownQualified, isGoProjectLocalCall, GO_STDLIB_PACKAGES, isGoBareName, goTypePositionTarget, goDotImportedSupertype, GO_TYPE_KINDS, matchGoAssertedCall, matchGoIndexedCall, matchGoChainedCall, preferGoBuildTwin, isGoAssertedLocal, isGoLocallyBound, isPythonFromImportModule, pythonAttrRefResolves, matchPythonTypedLocalCall, isImportShadowedByJsBinding, isManualMockPath, isTestSuitePath, matchNewReceiverCall, newReceiverClass, NEW_RECEIVER_SHAPE, isGuessedRecordAccessor, matchKotlinReceiverChain } from './name-matcher'; import { isVisibleCppMacro, clearCppMacroVisibility } from './cpp-macro-visibility'; import { isCppConstructorRef, matchCppConstructor } from './cpp-constructor'; import { isCppSupertypeRef, matchCppSupertype, clearCppSupertypeMemos } from './cpp-supertypes'; @@ -1554,6 +1554,18 @@ export class ReferenceResolver { // or nothing: Shopify never looks in another theme (see ./shopify-themes). const themeFile = matchShopifyThemeFile(ref, this.context); if (themeFile !== undefined) return themeFile; + // 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); + } // A TS/JS `super.run(…)` runs the method of the class's parent or a // nearer ancestor, or one outside the project: never a method found by diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 1965edd208..b486bd2a12 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -18078,7 +18078,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*[.(])`)); @@ -18209,6 +18210,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 ed669fb0515fdbe8be0eaac79c8306f873eb37ec Mon Sep 17 00:00:00 2001 From: danusha2345 Date: Thu, 24 Sep 2026 16:38:34 +0300 Subject: [PATCH 07/16] 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 4b83ee26e3..a5425f2272 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -611,7 +611,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 b486bd2a12..ad0cfcba23 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -17992,17 +17992,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) { @@ -18011,7 +18023,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; } @@ -18178,7 +18300,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, @@ -18186,19 +18309,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; } } @@ -18207,7 +18328,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; } /** @@ -18229,10 +18400,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); @@ -18265,12 +18433,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 477ac6790933fa73cde30a50b11dc908e35ed4cb Mon Sep 17 00:00:00 2001 From: mixxer Date: Thu, 24 Sep 2026 12:53:25 +0900 Subject: [PATCH 08/16] 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 | 73 ++++++++-- 8 files changed, 696 insertions(+), 26 deletions(-) create mode 100644 __tests__/kotlin-object-literal.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a5425f2272..b004433223 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -613,6 +613,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 a481cb8a45..ef64d902ed 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -2035,6 +2035,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 738f13af9b..07fad0179a 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[] = [ @@ -6078,6 +6099,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. @@ -6342,4 +6411,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 6a7735a20b..85b2ac9ca9 100644 --- a/codegraph-kernel/src/java.rs +++ b/codegraph-kernel/src/java.rs @@ -959,13 +959,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; }; @@ -975,7 +990,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 fb4d4cdc52..70efdef437 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). @@ -893,10 +905,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() { @@ -961,6 +978,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) { @@ -1400,6 +1421,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 1f9024dc85..ea22931012 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -907,6 +907,7 @@ export class TreeSitterExtractor { private sourceAsWritten: string | null = null; // Memoized reExportAsWritten verdict (per-extractor = per-file). private reExportsAsWritten: boolean | null = null; + private kotlinObjectScopeDepth = 0; constructor( filePath: string, @@ -1998,6 +1999,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 @@ -2046,7 +2055,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 @@ -6970,16 +6984,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'; @@ -6988,14 +7008,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, @@ -7011,6 +7040,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` @@ -7476,6 +7628,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 ad0cfcba23..c4017315b3 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -12063,6 +12063,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 */ @@ -12082,12 +12102,15 @@ 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; + }; // A C or C++ name written from the global scope (`::hpb::CreateMessage`, // `::operator delete`) is the declaration of exactly that name. The suffix @@ -12109,7 +12132,21 @@ export function matchByQualifiedName( }; } - let candidates = cppQualifierSpecializationPool(cppSpecializationPool(keepForRef(context.getNodesByQualifiedName(ref.referenceName)), ref, context), ref, context); + // 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 = cppQualifierSpecializationPool(cppSpecializationPool(keepForRef(context.getNodesByQualifiedName(normalizedQualifiedRef)), ref, context), ref, context); // 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. // That own using ends the lookup here, and resolveOne drops it (an import @@ -12184,8 +12221,12 @@ export function matchByQualifiedName( const parts = ref.referenceName.split(/[:.]/); const lastName = parts[parts.length - 1]; if (lastName) { + // Inheritance uses normalized JVM scope names; other qualified names + // retain upstream's scope-boundary and specialization checks. const partialCandidates = cppQualifierSpecializationPool(cppSpecializationPool(keepForRef(context.getNodesByName(lastName)) - .filter((candidate) => endsAtScopeBoundary(candidate.qualifiedName, ref.referenceName)), ref, context), ref, context); + .filter((candidate) => isInheritanceRef(ref) + ? endsWithQualifiedSegment(candidate.qualifiedName, normalizedQualifiedRef) + : endsAtScopeBoundary(candidate.qualifiedName, ref.referenceName)), ref, context), ref, context); const ownLanguage = partialCandidates.filter((candidate) => sameLanguageFamily(candidate.language, ref.language)); const pool = ownLanguage.length > 0 ? ownLanguage : partialCandidates; const chosen = preferCallSiteFile(pool, ref.filePath)[0]; @@ -13081,6 +13122,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 @@ -17428,6 +17474,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 looked = matchCppChainOnWrittenType(m[1], m[2], ref, context); @@ -17503,6 +17550,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]; @@ -17542,6 +17590,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` @@ -20202,6 +20251,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 @@ -27479,7 +27534,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 825746180c04bfe5f654b10546d98767564c12f6 Mon Sep 17 00:00:00 2001 From: mixxer Date: Thu, 24 Sep 2026 13:21:40 +0900 Subject: [PATCH 09/16] 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 07fad0179a..a867e2b934 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -6481,5 +6481,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 c4017315b3..cab7c1a3bc 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -12229,7 +12229,16 @@ export function matchByQualifiedName( : endsAtScopeBoundary(candidate.qualifiedName, ref.referenceName)), ref, context), ref, context); const ownLanguage = partialCandidates.filter((candidate) => sameLanguageFamily(candidate.language, ref.language)); const pool = ownLanguage.length > 0 ? ownLanguage : partialCandidates; - const chosen = preferCallSiteFile(pool, 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) && pool.length > 1 + ? pool.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(pool, ref.filePath)[0]; if (chosen) { return { original: ref, From 8a60cef7963fb20bad75decbf40dd70127de41a5 Mon Sep 17 00:00:00 2001 From: mixxer Date: Thu, 24 Sep 2026 16:54:55 +0900 Subject: [PATCH 10/16] 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 ef64d902ed..a11981afcb 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -2043,13 +2043,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 a867e2b934..27c75ee874 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -6099,7 +6099,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 85b2ac9ca9..e565ae8688 100644 --- a/codegraph-kernel/src/java.rs +++ b/codegraph-kernel/src/java.rs @@ -961,9 +961,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 ea22931012..597acbe571 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -7012,12 +7012,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 @@ -7058,8 +7054,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 2bdce89d1ac4a1e85710340b94eb1f5c6aa9ef58 Mon Sep 17 00:00:00 2001 From: mixxer Date: Thu, 24 Sep 2026 17:56:48 +0900 Subject: [PATCH 11/16] 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 27c75ee874..0c050fba75 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 cab7c1a3bc..7b79e0cb04 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -1287,15 +1287,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, @@ -21078,7 +21078,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 de0a201bdd645f2efc28b7c2f87ab46333f59e6e Mon Sep 17 00:00:00 2001 From: mixxer Date: Mon, 28 Sep 2026 08:11:18 +0900 Subject: [PATCH 12/16] 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 7b79e0cb04..7eb41d4305 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -13131,10 +13131,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 00faf8706154442bd60ff083774b036775d105cf Mon Sep 17 00:00:00 2001 From: mixxer Date: Mon, 5 Oct 2026 00:16:33 +0900 Subject: [PATCH 13/16] fix: adapt PR to current upstream behavior --- CHANGELOG.md | 7 +++---- __tests__/resolution.test.ts | 3 +++ codegraph-kernel/src/kotlin.rs | 8 +------- src/extraction/tree-sitter.ts | 7 +------ src/resolution/name-matcher.ts | 2 +- 5 files changed, 9 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b004433223..52500853e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -361,6 +361,9 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In JavaScript, TypeScript, Python, Go, Rust, Java and Kotlin, a function named in the value of a top-level constant or variable, or of a class field, like `const routes = { home }`, `HANDLERS = {"home": home}`, a Go `map[string]func()` table, a Rust `static` list of functions, `static handlers = { click: onClick }`, a Java `List.of(Registry::home)` field or a Kotlin `listOf(::home)` property, is now referenced once, by the constant, variable, field or property that holds it. The file or class around it no longer also shows up as a second reference from the same place, so a function's callers and impact list each table once. - `codegraph sync` and the file watcher now follow an edit that changes which declaration a module exports as its default, like `export default A` becoming `export default B` while `A` stays, or the module dropping its default. References through `import Y from './x'`, `{ default as Y }`, `require('./x').default`, a barrel that passes the default on or a route that renders it as a layout, and calls on a value typed by it, now link to the new default, or stay unlinked, as a fresh index has them. Before, they kept pointing at the old declaration until the importing file changed or the project was indexed again. - `codegraph sync` and the file watcher now relink a named import that a name match had linked to a declaration elsewhere while its module didn't export the name, once an edit makes the module export it, like `import { EmptyState } from './layouts/shared'` after the barrel gains `export { EmptyState } from './EmptyState'`. An import a name match had linked to a class the module exported only as its default comes unlinked once the module stops exporting it. Before, the import, the references through it and calls on values typed by it kept the old link until the importing file changed or the project was indexed again. +- 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 @@ -611,10 +614,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 0c050fba75..4b5b6b2c67 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 70efdef437..255611a4a9 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 597acbe571..6c70b00ca5 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -2055,12 +2055,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 7eb41d4305..965395eb30 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -21203,7 +21203,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')) : []; // An untyped python attribute chain is a guess only through its head // noun: `self.session` names a `SessionBase` and `self.client` a // `ClientMixin`, but mealie's `database.group_shopping_lists` no From ab82412979617f8594f30351cdd2d0c78bbf4c59 Mon Sep 17 00:00:00 2001 From: mixxer Date: Mon, 5 Oct 2026 00:19:09 +0900 Subject: [PATCH 14/16] 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(); }); From 11efa862763031c9ca943c105a78bc61a16f8b31 Mon Sep 17 00:00:00 2001 From: mixxer Date: Sat, 24 Oct 2026 11:46:38 +0900 Subject: [PATCH 15/16] fix(kotlin): preserve both call-result type helpers after rebase --- src/resolution/name-matcher.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 965395eb30..0e830e94f5 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -18283,7 +18283,7 @@ function kotlinDeclaredTypeIn( 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); + return kotlinChainedCallResultType(segments.length > 0 ? segments.join('::') : undefined, last, ref, context); } /** Kotlin stdlib functions whose result is always a library type. */ @@ -18307,7 +18307,7 @@ const KOTLIN_ARGUMENT_PASSTHROUGH = new Set(['requireNotNull', 'checkNotNull']); * `HardwareLock::Lease`). KOTLIN_EXTERNAL_TYPE for a call on a type the * project does not declare (`Executors.newSingleThreadScheduledExecutor()`). */ -function kotlinCallResultType( +function kotlinChainedCallResultType( owner: string | undefined, fn: string, ref: UnresolvedRef, From 7afee836d8488b9533a18f3dcd11c9df708462a5 Mon Sep 17 00:00:00 2001 From: mixxer Date: Sat, 24 Oct 2026 12:02:37 +0900 Subject: [PATCH 16/16] fix(resolution): retain inherited framework handler references --- src/resolution/name-matcher.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 0e830e94f5..399e6403e7 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -13131,9 +13131,10 @@ export function resolveMethodOnType( /** Recursion guard for the supertype/conformance walk. */ depth = 0, ): ResolvedRef | 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; + // Frameworks can name an inherited handler with a qualified reference. + // Inheritance refs still name a type, not a callable receiver. + if (ref.referenceKind !== 'calls' && ref.referenceKind !== 'function_ref' && + ref.referenceKind !== 'references') return null; // Look up methods by name and match by qualifiedName ending in // `::`. This works whether the method is defined @@ -20259,11 +20260,9 @@ 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; + // A dotted/scoped name can be a framework's handler reference, while + // inheritance refs must stay with type lookup. + if (ref.referenceKind !== 'calls' && ref.referenceKind !== 'references') return null; // Parse method call patterns like "obj.method" or "Class::method". The method // part allows trailing `:` keywords so Objective-C selectors resolve