Skip to content
Closed
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### Fixes

- Java and Kotlin inheritance now resolves nested types more precisely, and anonymous implementations retain their members after re-indexing.
- In TypeScript, an awaited call with type arguments like `await fetchApi<User>("/me")` or `await api.get<T>(url)` now links to the function it calls. Before, the call was dropped, so typed fetch wrappers looked like they had no callers. (#2586)
- In JavaScript and TypeScript, a call or `new` in a parameter's default value, like `function renderWithNote(note = buildNote())`, a destructured `{ size = defaultSize() }` or `constructor(private svc = new Service())`, now counts as the function's own call. Before, it was dropped, so `buildNote` was missing that caller and impact on it came back incomplete. Re-index JavaScript and TypeScript projects after upgrading. Thanks @inth3shadows for the report and the pull request. (#2588)
- In React, a component rendered at module scope, in a top-level constant or inside a test's `it(…)` callback, like `createRoot(el).render(<App />)` or `render(<Card />)`, now counts as rendered there, so an app's entry reaches its root component and a component's tests show among its callers. (#2587)
Expand Down
40 changes: 40 additions & 0 deletions __tests__/extraction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2035,6 +2035,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
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();
}
});
});
208 changes: 208 additions & 0 deletions __tests__/resolution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', '<Probe$anon@12:8>', 'ProbeTest::otherTest::<Probe$anon@12:8>', '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[] = [
Expand Down Expand Up @@ -6260,6 +6311,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 `<Inner$anon@...>`) 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.
Expand Down Expand Up @@ -6600,4 +6699,113 @@ func (n *Node) Size() int {
expect(selfLoops(cg, 'node.go')).toEqual(['Size']);
}, 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([]);
});
});
});
Loading