feat(code-graph): Kotlin call-edge extraction — bare-token parity with Java/Go/Rust (#2574)

Kotlin chunks fine (bundled grammar, symbol-typed chunks) but CALL_CONFIG
had no kotlin entry, so code sync on Kotlin repos produced zero call edges
and code_callers/code_callees/code_blast/code_flow returned empty.

Two grammar quirks made this more than a config row:
- tree-sitter-kotlin defines no fields on call_expression, and
  extractCalleeName required calleeFieldName (the interface comment
  claimed a text-scan fallback that the code never had). Added an
  explicit calleeFirstNamedChild option — the callee is positional
  (namedChild(0)) — reusable by any future field-less grammar; corrected
  the stale comment.
- receiver calls parse as navigation_expression, unknown to the unwrap
  loop. Added a case alongside member_expression (TS) / scoped_identifier
  (Rust) that walks to the trailing navigation_suffix identifier, so
  receiver.method(...) resolves to the method, not the receiver.

No behavior change for the existing 8 languages: the new callee path only
activates via calleeFirstNamedChild, and navigation_expression does not
occur in the other configured grammars.

Validated on a private production Kotlin codebase (Spring + QueryDSL,
5,143 .kt files): 0 parse errors, 10,621 chunks, 89,279 call edges,
5,586 distinct callees.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eungoo Jung
2026-07-27 16:59:45 -07:00
committed by GitHub
co-authored by Claude Fable 5
parent b30f0aa7cb
commit ae8753c872
2 changed files with 77 additions and 7 deletions
+29 -7
View File
@@ -78,10 +78,10 @@ export const WALK_DEPTH_CAP = 32;
/**
* Which languages get receiver-type resolution at extraction time. Per D18
* from eng review — JS/TS/TSX + Python at full depth; Ruby/Go/Rust/Java
* keep TODAY's bare-token call edges. Honest scope: tree-sitter shapes are
* very different across these languages and writing+testing per-language
* scope walkers for all of them is a v0.35 expansion.
* from eng review — JS/TS/TSX + Python at full depth; Ruby/Go/Rust/Java/
* Kotlin keep TODAY's bare-token call edges. Honest scope: tree-sitter
* shapes are very different across these languages and writing+testing
* per-language scope walkers for all of them is a v0.35 expansion.
*/
const RECEIVER_RESOLUTION_LANGS: ReadonlySet<SupportedCodeLanguage> = new Set([
'typescript',
@@ -93,12 +93,16 @@ const RECEIVER_RESOLUTION_LANGS: ReadonlySet<SupportedCodeLanguage> = new Set([
/**
* Per-language call-expression configuration. `callNodeTypes` lists the
* AST node types that are call sites in that language. `calleeFieldName`
* optionally names the child field that holds the callee expression;
* when absent, the call-site text itself is scanned for the identifier.
* names the child field that holds the callee expression. Grammars that
* define no fields on their call node (Kotlin: `call_expression =
* expression call_suffix`) set `calleeFirstNamedChild` instead — the
* callee is positional, so namedChild(0) IS the callee.
*/
interface CallConfig {
callNodeTypes: Set<string>;
calleeFieldName?: string;
/** Callee is namedChild(0) — for grammars whose call node has no fields. */
calleeFirstNamedChild?: boolean;
}
const CALL_CONFIG: Partial<Record<SupportedCodeLanguage, CallConfig>> = {
@@ -110,6 +114,11 @@ const CALL_CONFIG: Partial<Record<SupportedCodeLanguage, CallConfig>> = {
go: { callNodeTypes: new Set(['call_expression']), calleeFieldName: 'function' },
rust: { callNodeTypes: new Set(['call_expression', 'method_call_expression']), calleeFieldName: 'function' },
java: { callNodeTypes: new Set(['method_invocation']), calleeFieldName: 'name' },
// tree-sitter-kotlin defines no fields on call_expression; the callee is
// the first named child (simple_identifier for bare calls,
// navigation_expression for `receiver.method(...)` — resolved to the
// method name by the navigation_expression case in extractCalleeName).
kotlin: { callNodeTypes: new Set(['call_expression']), calleeFirstNamedChild: true },
};
/**
@@ -120,7 +129,11 @@ const CALL_CONFIG: Partial<Record<SupportedCodeLanguage, CallConfig>> = {
* null to skip the edge.
*/
function extractCalleeName(node: any, cfg: CallConfig): string | null {
const callee = cfg.calleeFieldName ? node.childForFieldName(cfg.calleeFieldName) : null;
const callee = cfg.calleeFieldName
? node.childForFieldName(cfg.calleeFieldName)
: cfg.calleeFirstNamedChild
? (node.namedChild?.(0) ?? null)
: null;
if (!callee) return null;
// Unwrap common wrappers until we hit an identifier-shaped node.
@@ -155,6 +168,15 @@ function extractCalleeName(node: any, cfg: CallConfig): string | null {
if (name) { cur = name; continue; }
return null;
}
// navigation_expression (Kotlin): `receiver.method` — the callee is the
// simple_identifier inside the trailing navigation_suffix. No fields on
// this node either, so walk to the last named child's identifier.
if (cur.type === 'navigation_expression') {
const suffix = cur.namedChild?.(cur.namedChildCount - 1);
const ident = suffix?.namedChild?.(0);
if (ident) { cur = ident; continue; }
return null;
}
// Fallback: read the node text and take the last identifier-looking token.
const m = (cur.text as string).match(/([A-Za-z_][A-Za-z0-9_]*)\s*$/);
return m ? sanitizeIdent(m[1]!) : null;
+48
View File
@@ -129,6 +129,54 @@ class Foo {
});
});
describe('Layer 5 (A1) — Kotlin call extraction', () => {
test('captures bare function calls', async () => {
const src = `
class Foo {
fun helper(): Int = 1
fun caller(): Int { return helper() }
}
`.trim();
const result = await chunkCodeTextFull(src, 'src/Foo.kt');
expect(result.edges.map(e => e.toSymbol)).toContain('helper');
});
test('captures navigation-expression method calls (receiver.method)', async () => {
const src = `
class Greeter {
fun format(name: String): String = "Hello, " + name
}
fun main() {
val greeter = Greeter()
println(greeter.format("world"))
}
`.trim();
const result = await chunkCodeTextFull(src, 'src/Main.kt');
const syms = result.edges.map(e => e.toSymbol);
// receiver.method(...) — the navigation_expression resolves to the
// method's simple_identifier, not the receiver.
expect(syms).toContain('format');
expect(syms).toContain('println');
});
test('captures calls on chained receivers', async () => {
const src = `
fun caller(input: String): String {
return input.trim()
}
`.trim();
const result = await chunkCodeTextFull(src, 'src/Chain.kt');
expect(result.edges.map(e => e.toSymbol)).toContain('trim');
});
test('all edges typed as calls', async () => {
const src = 'fun f(): Int { return g() }\nfun g(): Int = 1';
const result = await chunkCodeTextFull(src, 'src/Typed.kt');
expect(result.edges.length).toBeGreaterThan(0);
for (const e of result.edges) expect(e.edgeType).toBe('calls');
});
});
describe('Layer 5 (A1) — findChunkForOffset mapping', () => {
test('finds innermost chunk for a given offset', () => {
const source = [