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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,10 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- In JavaScript and TypeScript, a default import now links to what the module's `export default` statement exports, wherever the statement sits in the file. Before, a module that declares its default, like `export default function Vans() {…}` or `export default class Store {…}`, had its default import linked to the first function it exports instead: in a React Router data-router app, where a page exports its `loader` or `action` above the page component, `import Vans, { loader as vansLoader } from './pages/Vans'` made the route `/vans` and every use of `Vans` lead to the loader. A component file that also exports a styled or memoized component no longer hands its default import to that component, and an anonymous default, like `export default function () {…}` or `export default () => …`, is no longer linked to a function exported beside it or nested inside it. A module loaded with `require('./x').default` is read the same way. Re-index JavaScript and TypeScript projects after upgrading.
- In React and Vue, a tag for a component the file imports from a package, like `<Button>` from antd, `<Typography>` from `@mui/material/Typography`, `<Link>` from react-router-dom or `<Card>` from ant-design-vue, no longer links to a component, function or class in your repository that only shares its name. Before, the tag linked to whatever the repository had by that name, so `codegraph_explore`, callers and impact followed renders that never happen: in SigNoz, antd's `<Button>` tags showed up as callers of a styled link on its 404 page, and in Material UI dashboards like mantis and berry, `<Typography>` and `<Button>` tags showed up as callers of the theme's style overrides. A package here is one that a `package.json` above the file lists and that no file in the repository answers to. A tag imported through a path alias like `components/Modal`, from a workspace package of the repository, or under a name no `package.json` lists still links as before. Re-index React and Vue projects after upgrading.
- In React with TypeScript, a component that renders a generic component and passes it type arguments, like outline's `<PaginatedList<Document> items={…} />` or excalidraw's `<DropdownMenuItemContentRadio<"contain" | "overlap"> …>`, now links to the component it renders. Before, a tag followed by type arguments was skipped, so the generic component looked unused by the screens that render it, and `codegraph_explore`'s call paths, callers and impact stopped short of it. Re-index React projects after upgrading.
- 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 @@ -1954,6 +1954,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