Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
40 changes: 40 additions & 0 deletions __tests__/extraction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
17 changes: 17 additions & 0 deletions __tests__/fixtures/kernel-parity/torture.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
19 changes: 11 additions & 8 deletions __tests__/kernel-kotlin-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
108 changes: 108 additions & 0 deletions __tests__/kotlin-object-literal.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof extract>) => r.nodes.filter(n => n.kind === 'class' && n.name.includes('$anon@'));
const refs = (r: ReturnType<typeof extract>, 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<IFoo.Stub>.Inner<List<String>>() {}');
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<IFoo.Stub>, 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();
}
});
});
Loading