From ae8753c8722daf9c6d587e2a1946081ec770baf4 Mon Sep 17 00:00:00 2001 From: Eungoo Jung Date: Tue, 28 Jul 2026 08:59:45 +0900 Subject: [PATCH] =?UTF-8?q?feat(code-graph):=20Kotlin=20call-edge=20extrac?= =?UTF-8?q?tion=20=E2=80=94=20bare-token=20parity=20with=20Java/Go/Rust=20?= =?UTF-8?q?(#2574)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/core/chunkers/edge-extractor.ts | 36 +++++++++++++++++----- test/edge-extractor.test.ts | 48 +++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 7 deletions(-) diff --git a/src/core/chunkers/edge-extractor.ts b/src/core/chunkers/edge-extractor.ts index 6245f62a8..2b7ff2127 100644 --- a/src/core/chunkers/edge-extractor.ts +++ b/src/core/chunkers/edge-extractor.ts @@ -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 = new Set([ 'typescript', @@ -93,12 +93,16 @@ const RECEIVER_RESOLUTION_LANGS: ReadonlySet = 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; calleeFieldName?: string; + /** Callee is namedChild(0) — for grammars whose call node has no fields. */ + calleeFirstNamedChild?: boolean; } const CALL_CONFIG: Partial> = { @@ -110,6 +114,11 @@ const CALL_CONFIG: Partial> = { 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> = { * 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; diff --git a/test/edge-extractor.test.ts b/test/edge-extractor.test.ts index 0f1abb0ad..d9dcfc22e 100644 --- a/test/edge-extractor.test.ts +++ b/test/edge-extractor.test.ts @@ -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 = [