diff --git a/CHANGELOG.md b/CHANGELOG.md index 06ca319d98..3ee191f621 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,12 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Fixes + +- Kotlin calls through a class property, a primary-constructor property, or a variable set from a function call now resolve on the declared type, and a call on a library type (such as `Regex` or a JDK class) no longer links to an unrelated project method with the same name. Chained receivers such as `engine.pump.drain()`, `this.engine.drain()` and `Mode.ON.next()` resolve the same way, as do properties inherited from a project base class and variables of the enclosing function used inside an anonymous `object : …`. Re-index Kotlin projects to pick this up. +- Kotlin `fun interface` declarations are indexed, and no longer hide the declaration that follows them. +- Java and Kotlin inheritance now resolves nested types more precisely, and anonymous implementations retain their members after re-indexing. + ## [1.6.2] - 2026-10-03 diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index c6a522decf..f10a3d048b 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -1916,6 +1916,46 @@ 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 + // 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 { + 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__/fixtures/kernel-parity/torture.kt b/__tests__/fixtures/kernel-parity/torture.kt index c5531c55e2..cfafd47741 100644 --- a/__tests__/fixtures/kernel-parity/torture.kt +++ b/__tests__/fixtures/kernel-parity/torture.kt @@ -289,3 +289,20 @@ class SiblingAccessorK { register(fromInit) } } + +class ReceiverChainK(private val engine: EngineK) { + fun chains() { + engine.pump.drain() + this.engine.drain() + engine?.pump?.drain() + engine + .pump + .drain() + engine.a.b.c.drain() + engine.a.b.c.d.drain() + engine!!.pump.drain() + (engine).pump.drain() + engine.pump().drain() + super.toString().length.toString() + } +} diff --git a/__tests__/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/__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__/kotlin-property-receiver.test.ts b/__tests__/kotlin-property-receiver.test.ts new file mode 100644 index 0000000000..14106d4f8f --- /dev/null +++ b/__tests__/kotlin-property-receiver.test.ts @@ -0,0 +1,372 @@ +/** + * 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() {} + } +} +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 } +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 { + 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() } +} +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 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) + fun scheduled() { + val scheduler = java.util.concurrent.Executors.newSingleThreadScheduledExecutor() + 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() } +} +`); + // 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); + 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 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 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']); + // 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(); + 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 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/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index 14b5198f47..ab54162bc7 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -81,6 +81,57 @@ 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), + 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), + 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 = { + 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], + getNodesByLowerName: () => [target], getImportMappings: () => [], + getNodesByQualifiedName: () => [], getNodesByKind: () => [], + fileExists: () => true, readFile: () => null, + getProjectRoot: () => tempDir, getAllFiles: () => ['model.ts'], + } as ResolutionContext; + const ref: UnresolvedRef = { + fromNodeId: 'class:derived', referenceName: 'Base', referenceKind: 'extends', + filePath: 'model.ts', language: 'typescript', line: 2, column: 0, + }; + expect(matchByExactName(ref, context)?.targetNodeId).toBe(target.id); + } + }); + it('should match exact name references', () => { // Create a mock context const mockNodes: Node[] = [ @@ -6047,6 +6098,54 @@ in }); }); + 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 + // constructor type is recorded verbatim with a dot (`Outer.Inner`). A + // real in-project nested type must still resolve — this is not an + // AOSP-specific concern (an absent AIDL Stub staying unresolved is + // correct there), it's the general case where the target genuinely + // exists in the index. + fs.writeFileSync( + path.join(tempDir, 'Host.java'), + `package p; +class Outer { static class Inner { public void run() {} } } +class Named extends Outer.Inner {} +class Host { + Object field = new Outer.Inner() { public void run() {} }; +} +` + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + const db = DatabaseConnection.open(path.join(tempDir, '.codegraph', 'codegraph.db')); + const innerId = db + .getDb() + .prepare("select id from nodes where kind = 'class' and name = 'Inner'") + .get() as { id: string } | undefined; + expect(innerId, 'the real Outer.Inner class should be indexed').toBeDefined(); + + const extendsTargets = db + .getDb() + .prepare( + `select src.name as sourceName, dst.id as targetId + from edges e + join nodes src on src.id = e.source + join nodes dst on dst.id = e.target + where e.kind = 'extends' and dst.id = ?` + ) + .all(innerId!.id) as Array<{ sourceName: string; targetId: string }>; + + const sourceNames = extendsTargets.map((r) => r.sourceName).sort(); + // `Named` (a real named class) and the anonymous class inside `Host` + // (named ``) must BOTH resolve their extends edge to + // the real `Inner` node — neither should stay in unresolved_refs. + expect(sourceNames.some((n) => n === 'Named')).toBe(true); + expect(sourceNames.some((n) => /Inner\$anon@/.test(n))).toBe(true); + }); + }); + describe('Bindings in a module that exports nothing (#1719)', () => { it('does not treat documentation headings as package imports', () => { // Inject the planned Markdown node shape without depending on its extractor. @@ -6311,4 +6410,113 @@ 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([]); + }); + + 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/codegraph-kernel/src/java.rs b/codegraph-kernel/src/java.rs index daaf49de0e..7f4822129a 100644 --- a/codegraph-kernel/src/java.rs +++ b/codegraph-kernel/src/java.rs @@ -944,13 +944,27 @@ 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) 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(); + if let Some(lt) = n.find('<') { + if lt > 0 { + n.truncate(lt); + } + } + let trimmed = n.trim().to_string(); + if trimmed.is_empty() { "Object".to_string() } else { trimmed } + }; + let mut short_type_name = strip_generic_and_qualifier(&raw_type_name); + if short_type_name.is_empty() { + short_type_name = "Object".to_string(); } - let anon_name = format!("<{type_name}$anon@{}>", node.start_position().row + 1); + let anon_name = format!("<{short_type_name}$anon@{}>", node.start_position().row + 1); let Some(row) = self.create_node("class", &anon_name, node, Extra::default()) else { return; }; @@ -960,7 +974,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 a224cb7265..44797f2ad7 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; @@ -202,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 { @@ -236,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; @@ -864,10 +892,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() { @@ -932,6 +965,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) { @@ -1257,9 +1294,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(); } } @@ -1363,6 +1408,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/docs/design/kotlin-kernel-port-checklist.md b/docs/design/kotlin-kernel-port-checklist.md index fcbdcd9c8a..d895d8ba4a 100644 --- a/docs/design/kotlin-kernel-port-checklist.md +++ b/docs/design/kotlin-kernel-port-checklist.md @@ -299,6 +299,12 @@ Hooks PRESENT (port each exactly): DEFER-SHIELDED in the kernel**: every `fun interface` (either pattern, probed) makes the tree `hasError=true`, so the kernel defers the whole FILE to wasm before the walker would run. **Do NOT port branches 2-3.** + *(2026-09-24: superseded for `fun interface` — the extractor's + `preParse` now blanks the `fun` of a `fun interface` declaration, + offset-preserving, so such a file parses cleanly and the kernel extracts + it; the wasm recovery branches no longer fire for it. The old defer also + lost the declaration AFTER the `fun interface` when a doc comment sat + between them.)* Walker rule: port branch 1 only; a `defer:` on has_error covers the rest. (The parity suite still needs a fun-interface fixture asserting the kernel defers and the wasm arm serves the pinned output.) diff --git a/src/extraction/languages/kotlin.ts b/src/extraction/languages/kotlin.ts index c41b2cd38b..d5c7f4bae0 100644 --- a/src/extraction/languages/kotlin.ts +++ b/src/extraction/languages/kotlin.ts @@ -161,6 +161,24 @@ function isFunInterfaceNode(node: SyntaxNode): boolean { return hasFun && hasInterfaceType; } +/** + * tree-sitter-kotlin predates functional interfaces (Kotlin 1.4): `fun + * interface Reply { … }` parses as a broken function declaration, and its + * error recovery swallows the NEXT declaration as well, so that interface and + * its methods go missing and its members surface as top-level functions. + * Blanking the `fun` of a `fun interface` declaration (three spaces for three + * letters, so every offset holds) lets it parse as the plain interface it is. + * Only a declaration position is touched: the start of a line, after optional + * visibility / multiplatform modifiers. + */ +export function blankKotlinFunInterface(source: string): string { + if (!source.includes('interface')) return source; + return source.replace( + /^([ \t]*(?:(?:public|private|internal|protected|expect|actual)[ \t]+)*)fun([ \t]+interface\b)/gm, + (_match, before: string, after: string) => `${before} ${after}`, + ); +} + /** * The qualifier of a function type's receiver — `DatabaseConfig.` in * `configure: (DatabaseConfig.Builder.() -> Unit)?`. The grammar reads a @@ -225,7 +243,7 @@ export function rewriteNewerKotlinSyntax(source: string): string { } function preParseKotlin(source: string): string { - return rewriteNewerKotlinSyntax(joinKotlinSplitConstructors(blankKotlinQualifiedReceivers(source))); + return rewriteNewerKotlinSyntax(joinKotlinSplitConstructors(blankKotlinQualifiedReceivers(blankKotlinFunInterface(source)))); } export const kotlinExtractor: LanguageExtractor = { diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 5c27523acc..2a17916789 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -406,6 +406,8 @@ const LITERAL_RECEIVER_TYPES = new Set([ /** * Languages whose member calls go through the TS/JS grammars. */ +/** A Kotlin receiver chain the resolver can type: `a.b`, `this.a`, up to four segments. */ +const KOTLIN_RECEIVER_CHAIN = /^(?:this|[A-Za-z_]\w*)(?:\.[A-Za-z_]\w*){1,3}$/; const TS_JS_CHAIN_LANGUAGES = new Set(['typescript', 'tsx', 'javascript', 'jsx']); /** Receiver node types (TS/JS grammars) that continue a member chain downward. */ @@ -548,6 +550,7 @@ export class TreeSitterExtractor { // point (this instance is the wasm fallback for a kernel-deferred file) — // don't blank it a second time. private sourceIsPreParsed = false; + private kotlinObjectScopeDepth = 0; constructor( filePath: string, @@ -1475,6 +1478,14 @@ export class TreeSitterExtractor { skipChildren = true; } } + // Kotlin `object : IFoo.Stub() { ... }` — structurally distinct from + // INSTANTIATION_KINDS (multiple repeatable `delegation_specifier` + // supertypes, no single constructor/type field), so it gets its own + // dedicated extraction rather than being folded into extractInstantiation. + else if (nodeType === 'object_literal') { + this.extractKotlinObjectLiteral(node); + skipChildren = true; + } // (Decorator handling lives inside the symbol-creating extractors // — extractClass / extractFunction / extractProperty — because the // decorator node sits BEFORE the symbol in the AST and the walker @@ -5074,6 +5085,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 && @@ -5495,16 +5521,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'; @@ -5513,14 +5545,19 @@ 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) 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 // 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, @@ -5536,6 +5573,128 @@ 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 cannot see 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` @@ -5980,6 +6139,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/index.ts b/src/resolution/index.ts index 1eaf14b145..596866a01e 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -21,7 +21,7 @@ import { isImportableKind, CPP_DEFINE_SIGNATURE, } from './types'; -import { isPythonSelfCall, matchJsStoreBindingCall, isUnresolvedJsMemberCall, isVisibleAcrossFiles, matchReference, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesCodeBoundary, gateLanguageMatch, dumpNameMatcherProfile, clearNameMatcherMemos, isRustNameInScope, CASE_INSENSITIVE_LANGUAGES } from './name-matcher'; +import { matchKotlinReceiverChain, isPythonSelfCall, matchJsStoreBindingCall, isUnresolvedJsMemberCall, isVisibleAcrossFiles, matchReference, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesCodeBoundary, gateLanguageMatch, dumpNameMatcherProfile, clearNameMatcherMemos, isRustNameInScope, CASE_INSENSITIVE_LANGUAGES } from './name-matcher'; import { isVisibleCppMacro, clearCppMacroVisibility } from './cpp-macro-visibility'; import { isCppConstructorRef, matchCppConstructor } from './cpp-constructor'; import { gateSwiftTypeTarget, clearSwiftTypeVisibility, swiftExtendedConformances } from './swift-type-visibility'; @@ -1068,6 +1068,18 @@ export class ReferenceResolver { // A local C++ object construction (`T obj(args)`, ref `ns::T::T/1`) // resolves ONLY to a constructor of the lexically nearest `T` (#1839). if (isCppConstructorRef(ref)) return matchCppConstructor(ref, this.context); + // A Kotlin call through a receiver chain (`engine.pump.drain()`) resolves + // on the chain's declared type, or gets no edge when that type is a + // library one. An untyped chain resolves as the bare method name, the ref + // the extractor emitted before it kept the chain. + if (ref.language === 'kotlin' && ref.referenceKind === 'calls') { + const chain = matchKotlinReceiverChain(ref, this.context); + if (chain && 'method' in chain) { + const bare = this.resolveOneInner({ ...ref, referenceName: chain.method }); + return bare ? { ...bare, original: ref } : null; + } + if (chain !== undefined) return this.gateLanguage(chain, ref); + } // Skip built-in/external references if (this.isBuiltInOrExternal(ref)) { diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index e0b4ad28b8..721ed58da7 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -1128,15 +1128,15 @@ const NO_NESTED_FUNCTIONS = new Set(['c', 'cpp']); const LOCAL_TYPE_KINDS = new Set(['class', 'struct', 'enum', 'interface', 'trait', 'type_alias']); /** - * A function nested inside another FUNCTION is only callable from within its - * container — Python, JS/TS, and every closure language scope it lexically. + * A function nested inside another function, or an anonymous-class method + * inside a function, is only callable from within that container. * Resolving a bare name from elsewhere to a nested local fabricates an edge * scope already rules out: `join(...)` in one function must never bind to a * `join` defined inside a DIFFERENT function (#1230). A candidate whose * qualifiedName parent is a same-file function/method is kept only when the - * ref originates inside that parent's line range. Class members are - * unaffected (their parent resolves to a class-like node), as are top-level - * symbols and C++ namespace-prefixed names (the prefix has no node). + * ref originates inside that parent's line range. Ordinary class members and + * top-level symbols have no enclosing function; C++ namespace prefixes have + * no function node. */ export function isLexicallyReachable( candidate: Node, @@ -5577,6 +5577,26 @@ export function matchByExactName( return null; } +/** + * Whether `qualifiedName` ends with `suffix` at a real `::` scope boundary — + * NOT a plain `String.endsWith`, which false-matches across an identifier + * boundary purely by character coincidence: `"Aaa::operator+"` ends with the + * literal substring `"a::operator+"` only because `Aaa` itself happens to + * end in the letter `a`, even though `"a"` there was never meant as a scope + * qualifier at all — it was a C++ receiver *variable* named `a` in a + * `a.operator+(b)` call, decoy-matching the unrelated `Aaa::operator+` + * method instead of leaving receiver-type inference (`matchMethodCall`) to + * find the real `V::operator+`. A match only counts when the + * suffix is the WHOLE qualifiedName, or the two characters immediately + * preceding it are a real `::` separator. + */ +function endsWithQualifiedSegment(qualifiedName: string, suffix: string): boolean { + if (qualifiedName === suffix) return true; + if (!qualifiedName.endsWith(suffix)) return false; + const boundary = qualifiedName.length - suffix.length; + return boundary >= 2 && qualifiedName[boundary - 1] === ':' && qualifiedName[boundary - 2] === ':'; +} + /** * Try to resolve by qualified name */ @@ -5596,14 +5616,31 @@ export function matchByQualifiedName( // must never resolve to a yaml/properties config node — that's a wrong edge // AND it hides the real callee. Drop those from both the exact and the partial // candidate sets so resolution falls through to method resolution below (#1180). - const keepForRef = (nodes: Node[]): Node[] => - ref.referenceKind === 'calls' - ? nodes.filter( - (n) => !(n.kind === 'constant' && (n.language === 'yaml' || n.language === 'properties')), - ) - : nodes; + const keepForRef = (nodes: Node[]): Node[] => { + let kept = nodes; + if (ref.referenceKind === 'calls') { + kept = kept.filter( + (n) => !(n.kind === 'constant' && (n.language === 'yaml' || n.language === 'properties')), + ); + } + return isInheritanceRef(ref) ? kept.filter(isSupertypeTarget) : kept; + }; - let candidates = keepForRef(context.getNodesByQualifiedName(ref.referenceName)); + // The GENERIC class-hierarchy walker's qualifiedName always joins scope + // with `::` (buildQualifiedName in tree-sitter.ts) regardless of source + // language, but an extends/implements clause's own text is recorded + // verbatim from the language's own syntax — so a Java/C# dotted supertype + // (`Outer.Inner`, `IFoo.Stub`) never matched a real indexed type's + // `::`-joined qualifiedName without normalizing the separator first. + // Scoped to ONLY inheritance references: several non-generic extractors + // (MyBatis XML statements, for one) deliberately build a MIXED + // qualifiedName that keeps literal dots from an already-dotted Java + // package/namespace string and adds `::` only at one specific boundary — + // blanket-normalizing every reference's dots to `::` mangled those. + const normalizedQualifiedRef = isInheritanceRef(ref) + ? ref.referenceName.replace(/\./g, '::') + : ref.referenceName; + let candidates = keepForRef(context.getNodesByQualifiedName(normalizedQualifiedRef)); // A C# `using X.Y;` names a namespace: one the project declares, else it is // the file's own (external) using — never another file's using of that name. if (ref.language === 'csharp' && ref.referenceKind === 'imports') { @@ -5671,9 +5708,28 @@ export function matchByQualifiedName( const parts = ref.referenceName.split(/[:.]/); const lastName = parts[parts.length - 1]; if (lastName) { + // The strict `::`-boundary check only matters where normalization + // actually ran (inheritance refs) — every other reference kind keeps + // the original, deliberately loose `endsWith` (e.g. Expo's JS call site + // binds a shortened alias like `Haptics` that only matches the END of + // the native module's `...::ExpoHaptics.method` qualifiedName by design, + // with no `::` immediately before it). const partialCandidates = keepForRef(context.getNodesByName(lastName)) - .filter((candidate) => candidate.qualifiedName.endsWith(ref.referenceName)); - const chosen = preferCallSiteFile(partialCandidates, ref.filePath)[0]; + .filter((candidate) => + isInheritanceRef(ref) + ? endsWithQualifiedSegment(candidate.qualifiedName, normalizedQualifiedRef) + : candidate.qualifiedName.endsWith(normalizedQualifiedRef) + ); + // `RecyclerView.LayoutManager` can exist in both AndroidX and the + // framework. For inheritance, pick the uniquely closest source tree; + // an equal-distance tie has no safe target. + const closest = isInheritanceRef(ref) && partialCandidates.length > 1 + ? partialCandidates.map((node) => ({ node, distance: computePathProximity(ref.filePath, node.filePath) })) + .sort((a, b) => b.distance - a.distance) + : null; + const chosen = closest + ? (closest[0]!.distance > closest[1]!.distance ? closest[0]!.node : null) + : preferCallSiteFile(partialCandidates, ref.filePath)[0]; if (chosen) { return { original: ref, @@ -5963,6 +6019,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; + // Look up methods by name and match by qualifiedName ending in // `::`. This works whether the method is defined // in-class (`class Foo { int bar() { ... } }`) or out-of-line in a separate @@ -6286,6 +6346,7 @@ export function matchCppCallChain( ref: UnresolvedRef, context: ResolutionContext, ): ResolvedRef | null { + if (ref.referenceKind !== 'calls') return null; const m = ref.referenceName.match(/^(.+)\(\)\.(\w+)$/); if (!m || !m[1] || !m[2]) return null; const cls = resolveCppCallResultType(m[1], ref, context); @@ -6308,6 +6369,7 @@ export function matchScopedCallChain( ref: UnresolvedRef, context: ResolutionContext, ): ResolvedRef | null { + if (ref.referenceKind !== 'calls') return null; const m = ref.referenceName.match(/^(.+)\(\)\.(\w+)$/); if (!m || !m[1] || !m[2]) return null; const inner = m[1]; @@ -6347,6 +6409,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` @@ -6478,14 +6541,22 @@ 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 && (n.endLine ?? n.startLine) <= enclosingEnd, ); + // Kotlin keeps no signature on a property, and a primary-constructor + // property (`class A(private val repo: Repo)`) is no node at all, so the + // declared type has to be read from the declaration itself. + if (ref.language === 'kotlin') { + return inferKotlinPropertyType(receiverName, field ?? null, enclosing, inFile, ref, context); + } if (!field || !field.signature) return null; // Signature shape: " " (extractField). Pull the type, @@ -6506,6 +6577,514 @@ 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 = kotlinFileLines(ref.filePath, context); + if (!lines) return null; + + 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); +} + +/** 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) { + if (n.id === owner.id || n.kind === 'file') continue; + if (n.startLine > owner.startLine && n.startLine <= ownerEnd && n.startLine < firstMember) { + firstMember = n.startLine; + } + } + 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; +} + + +/** 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, + 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]); + // 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 { … }`. + // 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*[.(])`)); + 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 + * 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; + } + // 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) { + 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, else a parameter, else a property + * the enclosing class inherits. Null when none names a type. + */ +function kotlinReceiverDeclaredType( + name: string, + ref: UnresolvedRef, + context: ResolutionContext, + depth = 0, +): string | null { + 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`); + 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; + } + } + 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); + 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; +} + +/** + * 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 = kotlinEnclosingClass(ref, context); + 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 own = kotlinOwnPropertyType(segment, owner, ref, context); + type = own !== undefined ? own : kotlinInheritedPropertyType(segment, owner, ref, 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()`) @@ -7628,6 +8207,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 @@ -7877,6 +8462,26 @@ export function matchMethodCall( } } + // Kotlin: a receiver whose type is known — a local bound to a call + // (`val lease = HardwareLock.tryBegin()`, typed by the callee's declared + // return type), a local constructor call, or a property — resolves on that + // type. A type the project does not declare (`Regex`, `Properties`, a JDK or + // Android class) has none of the project's methods, so the call gets NO + // edge instead of the name-only guesses below, which bind it to whatever + // project class declares a method of the same name. + if (ref.language === 'kotlin' && dotMatch && !objectOrClass!.includes('.')) { + const declared = nmTimedT('mc-kt-declared', ref, () => + kotlinReceiverDeclaredType(objectOrClass!, ref, context)); + if (declared === KOTLIN_EXTERNAL_TYPE) return null; + if (declared) { + const typedMatch = nmTimedT('mc-rmot', ref, () => resolveMethodOnType( + declared, methodName!, ref, context, 0.9, 'instance-method', + )); + if (typedMatch) return typedMatch; + if (!isProjectType(declared, ref, context)) return null; + } + } + // Object-literal namespace receiver (#1573): `api.call()` where `api` is a // same-file `const api = { call() {…}, get: () => {…} }`. Its members are // plain functions with bare names inside the constant's extent — no @@ -8048,7 +8653,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 @@ -8143,7 +8748,7 @@ export function matchMethodCall( // The owner type's own name — not its namespace (`eShop.ClientApp…` // shares `Client` with every `httpClient`) nor the method's. const cut = method.qualifiedName.lastIndexOf('::'); - const classWords = cut > 0 ? splitCamelCase(method.qualifiedName.slice(0, cut).split(/::|\./).pop()!) : []; + const classWords = cut > 0 ? splitCamelCase(method.qualifiedName.slice(0, cut).split(/::|\./).pop()!.replace(/^<(.+)\$anon@[^>]*>$/, '$1')) : []; let score = receiverWords.filter(w => classWords.some(cw => cw.toLowerCase() === w.toLowerCase()) ).length; @@ -9267,7 +9872,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.