mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 00:48:18 +00:00
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:
co-authored by
Claude Fable 5
parent
b30f0aa7cb
commit
ae8753c872
@@ -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;
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
Reference in New Issue
Block a user