fix(kotlin): walk a property's initializer scoped to the property (#693 for Kotlin)

The Kotlin property hook consumes the whole `property_declaration` subtree,
so the dispatcher only scanned it for function-as-value candidates and never
walked it for calls. Every call inside a property initializer was therefore
dropped from the graph entirely — not misattributed, gone:

    private val fieldLambda: () -> Unit = { target() }   // no caller
    private val samField = Runnable { target() }         // no caller
    private val plain = compute()                        // no caller
    private val delegated by lazy { compute() }          // no caller

That is exactly how Android/MSDK callbacks are declared, so anything reached
only through such a callback looked like it had no callers and its blast
radius came back far too small.

Fix: after minting the property node, walk its RHS — the named child after
the `=` token plus a `property_delegate` — through visitFunctionBody with the
property pushed on the scope stack. This is Go's #693/#744 fix ported;
tree-sitter-kotlin exposes no fields at all, hence the `=` anchor instead of
Go's `child_by_field_name("value")`.

Not touched, by design: the `scope == "local"` early return, the
hook-declined destructuring branch, and the declaration's own children
(modifiers, `val`/`var`, name+type, extension receiver, `getter`/`setter`).

Both arms move together — the Rust kernel and the TS extractor — so
kernel-kotlin-parity stays byte-identical; the torture fixture gains the
lambda/SAM/anonymous-object initializer shapes.

Measured on a 113-file Android/Kotlin app: strictly additive, 0 lost nodes /
edges / refs, +7 nodes (anonymous-object overrides that were invisible),
+109 edges (+47 calls, +35 instantiates, +20 references), and the real case
that motivated this — a `CameraFrameListener` field — now shows up as a
caller of the method it invokes.
This commit is contained in:
danusha2345
2026-08-05 22:18:48 +03:00
parent c65d56ceba
commit cf582f4274
7 changed files with 175 additions and 20 deletions
+1
View File
@@ -25,6 +25,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- On Windows, the Claude Code prompt hook written by `codegraph install` failed with "command not found" when hooks run through Git Bash, which needs the `.cmd` extension to find the launcher. The installer now writes the platform-correct command, and re-running `codegraph install` (or `codegraph upgrade`) repairs an existing install in place. (#1466) - On Windows, the Claude Code prompt hook written by `codegraph install` failed with "command not found" when hooks run through Git Bash, which needs the `.cmd` extension to find the launcher. The installer now writes the platform-correct command, and re-running `codegraph install` (or `codegraph upgrade`) repairs an existing install in place. (#1466)
- Python classes used as values — `return SomeSerializer` from a factory method, `handler = SomeClass` aliases, registry dicts and lists, and classes passed as arguments — now produce reference edges in the graph. Previously these idioms were invisible, so on Django and Django REST Framework projects, asking for a serializer's callers or the impact of editing it missed the views that actually use it. Re-index after upgrading to pick up the new edges. (#1478) - Python classes used as values — `return SomeSerializer` from a factory method, `handler = SomeClass` aliases, registry dicts and lists, and classes passed as arguments — now produce reference edges in the graph. Previously these idioms were invisible, so on Django and Django REST Framework projects, asking for a serializer's callers or the impact of editing it missed the views that actually use it. Re-index after upgrading to pick up the new edges. (#1478)
- When a file changed on disk after its last index sync, `codegraph_node` and `codegraph_explore` could return a different symbol's code under the requested name — current file bytes cut at outdated line positions — while presenting it as verbatim, trustworthy source. This hit hardest on projects queried through `projectPath` (for example, sub-projects of a monorepo), which have no live file watcher to flag pending edits. Both tools now verify each file against the index before showing sliced code: an out-of-date file is either shown whole with its full current source, or its code is withheld with a clear "changed on disk" notice — never served as a wrong slice. A fresh re-index restores normal output automatically. Thanks @inth3shadows for the thorough report and verification passes. (#1474) - When a file changed on disk after its last index sync, `codegraph_node` and `codegraph_explore` could return a different symbol's code under the requested name — current file bytes cut at outdated line positions — while presenting it as verbatim, trustworthy source. This hit hardest on projects queried through `projectPath` (for example, sub-projects of a monorepo), which have no live file watcher to flag pending edits. Both tools now verify each file against the index before showing sliced code: an out-of-date file is either shown whole with its full current source, or its code is withheld with a clear "changed on disk" notice — never served as a wrong slice. A fresh re-index restores normal output automatically. Thanks @inth3shadows for the thorough report and verification passes. (#1474)
- Kotlin properties that hold a lambda, a SAM callback or an anonymous object — `private val frameListener = CameraFrameListener { … }`, the way Android and MSDK callbacks are almost always declared — now contribute call edges. Previously everything inside such an initializer was dropped, so a function reached only through one of these callbacks looked like it had no callers at all and its blast radius came back far too small. Delegated properties (`by lazy { … }`) and plain initializers (`val x = compute()`) were affected the same way and are fixed too. Re-index with `codegraph index -f` after upgrading to pick up the new edges.
- The blast-radius section of `codegraph_explore` flagged "no covering tests found" whenever no test called a symbol directly — falsely branding helpers that tests exercise through their callers as untested (about 40% of flagged symbols in a measured sample). The check now follows caller chains up to 3 hops and reports indirect coverage as "tested via callers"; when nothing is found it states exactly what was checked instead of an unconditional warning. Thanks @inth3shadows for measuring the false-positive rate. (#1475) - The blast-radius section of `codegraph_explore` flagged "no covering tests found" whenever no test called a symbol directly — falsely branding helpers that tests exercise through their callers as untested (about 40% of flagged symbols in a measured sample). The check now follows caller chains up to 3 hops and reports indirect coverage as "tested via callers"; when nothing is found it states exactly what was checked instead of an unconditional warning. Thanks @inth3shadows for measuring the false-positive rate. (#1475)
## [1.5.0] - 2026-07-21 ## [1.5.0] - 2026-07-21
+55
View File
@@ -1940,6 +1940,61 @@ class Bar {
const cls = result.nodes.find((n) => n.kind === 'class' && n.name === 'Bar'); const cls = result.nodes.find((n) => n.kind === 'class' && n.name === 'Bar');
expect(cls?.qualifiedName).toBe('Bar'); expect(cls?.qualifiedName).toBe('Bar');
}); });
describe('property initializers are walked, attributed to the property (#693 for Kotlin)', () => {
// The property hook consumes the whole property_declaration subtree, so
// before this the initializer was only scanned for function-as-value
// candidates and every call inside it vanished from the graph. Android/MSDK
// callbacks are declared exactly this way (`private val l = Listener { … }`),
// so anything reached only through one looked like it had no callers at all.
const code = `
package repro
class Repro {
private val fieldLambda: () -> Unit = { target() }
private val samField = Runnable { target() }
private val plain = target()
private val delegated by lazy { target() }
private val anonObject = object : Runnable { override fun run() { target() } }
fun directCall() { target() }
fun lambdaInMethod() { run { target() } }
private fun target() {}
}
object Holder {
val topLevelLambda: () -> Unit = { hit() }
private fun hit() {}
}
`;
const callersOf = (target: string) => {
const result = extractFromSource('Repro.kt', code);
const byId = new Map(result.nodes.map((n) => [n.id, n]));
return result.unresolvedReferences
.filter((u) => u.referenceKind === 'calls' && u.referenceName === target)
.map((u) => byId.get(u.fromNodeId)?.name)
.sort();
};
it('a lambda / SAM / plain / delegated / object initializer calls FROM the property', () => {
// `run` is the anonymous object's override, extracted as its own node
// under `anonObject` — the same shape Go's initializer walk produces.
expect(callersOf('target')).toEqual([
'delegated',
'directCall',
'fieldLambda',
'lambdaInMethod',
'plain',
'run',
'samField',
]);
});
it('a property in an `object` singleton is a caller too', () => {
expect(callersOf('hit')).toEqual(['topLevelLambda']);
});
});
}); });
describe('Dart Extraction', () => { describe('Dart Extraction', () => {
@@ -50,6 +50,13 @@ val topDelegated by lazy { WidgetK(1) }
val (destA, destB) = makePair() val (destA, destB) = makePair()
val withGetter: Int val withGetter: Int
get() = 42 get() = 42
val initLambda: () -> Unit = { caller() }
val initSam = Runnable { caller() }
val initObject = object : Runnable {
override fun run() {
caller()
}
}
class WidgetK(val size: Int, private var name: String = defaultName()) { class WidgetK(val size: Int, private var name: String = defaultName()) {
val area: Int = size * size val area: Int = size * size
+3 -1
View File
@@ -5,7 +5,9 @@
* compiled from the vendored fwcd 0.3.8 C sources, the arc's first * compiled from the vendored fwcd 0.3.8 C sources, the arc's first
* vendored-grammar-C language) produces the SAME ExtractionResult as the * vendored-grammar-C language) produces the SAME ExtractionResult as the
* wasm TreeSitterExtractor over the checked-in torture fixture (torture.kt: * wasm TreeSitterExtractor over the checked-in torture fixture (torture.kt:
* the property hook's scope classification, extension-function receiver QNs * the property hook's scope classification and its initializer walk (a
* lambda / SAM / anonymous-object RHS attributing its calls to the property),
* extension-function receiver QNs
* (`WidgetK::extend`, the qualified `com::qext` bug) + the owner-contains * (`WidgetK::extend`, the qualified `com::qext` bug) + the owner-contains
* fallback, expect/actual → node DECORATORS (the KMP synthesizer feed), * fallback, expect/actual → node DECORATORS (the KMP synthesizer feed),
* the bodiless-vs-bodied class header asymmetry, comment-glued * the bodiless-vs-bodied class header asymmetry, comment-glued
+44 -5
View File
@@ -9,10 +9,10 @@
//! is source-order dependent) and extractModifiers (expect/actual platform //! is source-order dependent) and extractModifiers (expect/actual platform
//! modifiers → the node DECORATORS wire field, on every created node — the //! modifiers → the node DECORATORS wire field, on every created node — the
//! KMP synthesizer's input). Preserved on purpose: the FIELD_COUNT-0 dead //! KMP synthesizer's input). Preserved on purpose: the FIELD_COUNT-0 dead
//! cluster (no signatures, ZERO type-annotation refs), hook-consumed property //! cluster (no signatures, ZERO type-annotation refs), the bodiless-class
//! initializers emitting nothing, the bodiless-class header re-walk asymmetry, //! header re-walk asymmetry, enum-entry bodies being invisible, KDoc
//! enum-entry bodies being invisible, KDoc (`multiline_comment`) never being //! (`multiline_comment`) never being a docstring AND chain-breaking,
//! a docstring AND chain-breaking, comment-gluing into import/package extents, //! comment-gluing into import/package extents,
//! `@Anno(args)` emitting nothing while `@Anno` emits decorates, zero //! `@Anno(args)` emitting nothing while `@Anno` emits decorates, zero
//! instantiates refs (constructors are capitalized `calls`), the qualified- //! instantiates refs (constructors are capitalized `calls`), the qualified-
//! receiver `com::qext` bug, the paren-then-lambda `trailing()` garbage //! receiver `com::qext` bug, the paren-then-lambda `trailing()` garbage
@@ -87,6 +87,34 @@ fn strip_js_ws(s: &str) -> String {
s.chars().filter(|c| !is_js_space(*c)).collect() s.chars().filter(|c| !is_js_space(*c)).collect()
} }
/// A property's RHS: the named child right after the `=` token, plus a
/// `property_delegate` (`by lazy { … }`). Everything else a
/// `property_declaration` can hold is a declaration, not code — modifiers, the
/// `val`/`var` keyword, the name+type, an extension receiver's type and type
/// parameters, and `getter`/`setter` — so it stays unwalked. (Go's #693 fix
/// walks the `value` field for the same reason; this grammar exposes no fields
/// at all, hence the `=` anchor.)
fn property_initializers<'t>(node: Node<'t>) -> Vec<Node<'t>> {
let mut out: Vec<Node<'t>> = Vec::new();
let mut after_eq = false;
for i in 0..node.child_count() {
let Some(c) = node.child(i) else { continue };
if !c.is_named() {
if c.kind() == "=" {
after_eq = true;
}
continue;
}
if after_eq {
out.push(c);
after_eq = false;
} else if c.kind() == "property_delegate" {
out.push(c);
}
}
out
}
struct Scope { struct Scope {
row: u32, row: u32,
kind: &'static str, kind: &'static str,
@@ -641,7 +669,18 @@ impl<'t> Walker<'t> {
// The `type`-field signature read is dead (zero fields) → signature // The `type`-field signature read is dead (zero fields) → signature
// undefined; NO docstring/visibility/isStatic — the modifiers merge in // undefined; NO docstring/visibility/isStatic — the modifiers merge in
// create_node still decorates expect/actual properties. // create_node still decorates expect/actual properties.
self.create_node(kind, &name, node, Extra::default()); let row = self.create_node(kind, &name, node, Extra::default());
// Walk the initializer ATTRIBUTED to the declared symbol (#693, the Go
// fix, ported): without this the subtree is only fn-ref-scanned, so a
// lambda / SAM / object initializer (`val cb = Runnable { target() }` —
// the idiomatic Android callback field) contributed NO call edge at all.
if let Some(row) = row {
self.stack.push(Scope { row, kind, name: name.clone() });
for init in property_initializers(node) {
self.visit_function_body(init);
}
self.stack.pop();
}
true true
} }
+23 -13
View File
@@ -278,10 +278,18 @@ Hooks PRESENT (port each exactly):
but createNode's extractModifiers merge still runs, so `expect val` / but createNode's extractModifiers merge still runs, so `expect val` /
`actual val` DO get decorators. Return true → the dispatcher runs `actual val` DO get decorators. Return true → the dispatcher runs
`scanFnRefSubtree(node, 0)` (capture-only, halts at nested `scanFnRefSubtree(node, 0)` (capture-only, halts at nested
function/lambda types) and NEVER descends → **property initializers function/lambda types) and never descends on its own. **The hook itself
emit NO calls/instantiates refs anywhere** (`val SHARED = WidgetK(0)` then walks the property's RHS under the property's scope** — the named
→ nothing; `by lazy { compute() }` → nothing, the scan halts at the child after the `=` token plus a `property_delegate` — via
lambda_literal). Consequences pinned in `extract-torture.txt`. `ctx.visitFunctionBody`, so `val SHARED = WidgetK(0)`, `val cb =
Runnable { hit() }` and `by lazy { compute() }` all emit their calls
FROM the property node (Go's #693 initializer walk, ported). The
declaration's own children — modifiers, `val`/`var`, the name+type, an
extension receiver's type and type parameters, `getter`/`setter` — are
NOT walked: a same-line `val c get() = f()` still emits nothing, a
next-line accessor still attributes to the class, and a
hook-DECLINED destructuring RHS is still invisible.
Consequences pinned in `extract-torture.txt`.
2. **`lambda_literal` after a fun-interface ERROR (:139-143)** and 2. **`lambda_literal` after a fun-interface ERROR (:139-143)** and
3. **fun-interface misparse recovery (:145-214)** (ERROR/ 3. **fun-interface misparse recovery (:145-214)** (ERROR/
function_declaration shapes; `isFunInterfaceNode` :46; Pattern 1 walks function_declaration shapes; `isFunInterfaceNode` :46; Pattern 1 walks
@@ -391,7 +399,7 @@ Hooks ABSENT (the walker must NOT do these): `preParse`, `resolveName`,
| `anonymous_initializer` (`init { }`) | no branch | recursed → its statements' calls → **`calls` refs FROM THE CLASS node**; its `val` locals → hook 'local' → nothing (pinned: `calls "register" from=class:WidgetK`) | | `anonymous_initializer` (`init { }`) | no branch | recursed → its statements' calls → **`calls` refs FROM THE CLASS node**; its `val` locals → hook 'local' → nothing (pinned: `calls "register" from=class:WidgetK`) |
| `secondary_constructor` | no branch | **NO constructor node**; recursed → body calls attribute to the CLASS (`calls "log" from=class:WidgetK`); the `constructor_delegation_call`'s value_arguments still feed fn-ref capture | | `secondary_constructor` | no branch | **NO constructor node**; recursed → body calls attribute to the CLASS (`calls "log" from=class:WidgetK`); the `constructor_delegation_call`'s value_arguments still feed fn-ref capture |
| `getter`/`setter` as SIBLINGS (accessor on its own line) | no branch | recursed → accessor-body calls attribute to the CLASS (or file). See §Properties for the sibling/child split | | `getter`/`setter` as SIBLINGS (accessor on its own line) | no branch | recursed → accessor-body calls attribute to the CLASS (or file). See §Properties for the sibling/child split |
| `object_literal` (`object : T { … }` initializer) | no branch anywhere | never a node; see §Body walker for the method-leak quirk | | `object_literal` (`object : T { … }` initializer) | no branch anywhere | never a node itself; inside a PROPERTY initializer the hook's walk reaches its `fun`s, which leak out as FUNCTIONS under the property (see §Body walker for the same method-leak quirk) |
| `file_annotation` (`@file:JvmName("x")`) | no branch | recursed; its value_arguments feed fn-ref capture (string args → nothing). No decorates ref | | `file_annotation` (`@file:JvmName("x")`) | no branch | recursed; its value_arguments feed fn-ref capture (string args → nothing). No decorates ref |
| INSTANTIATION_KINDS (354-361) | **no kotlin member** | extractInstantiation:4610 is **UNREACHABLE** for kotlin — constructor calls `Foo()` are call_expressions → plain `calls` refs named `Foo` (capitalized). Kotlin emits **zero `instantiates` refs**, ever | | INSTANTIATION_KINDS (354-361) | **no kotlin member** | extractInstantiation:4610 is **UNREACHABLE** for kotlin — constructor calls `Foo()` are call_expressions → plain `calls` refs named `Foo` (capitalized). Kotlin emits **zero `instantiates` refs**, ever |
| `impl_item`:1274 / property_signature:1282 / export_statement / swift property:1121 | never | not kotlin node kinds (the swift `property_declaration` branch at 1121-1193 is gated `language === 'swift'` — kotlin property_declarations never enter it) | | `impl_item`:1274 / property_signature:1282 / export_statement / swift property:1121 | never | not kotlin node kinds (the swift `property_declaration` branch at 1121-1193 is gated `language === 'swift'` — kotlin property_declarations never enter it) |
@@ -659,7 +667,8 @@ refs — kotlin emits NO instantiates, §dispatch table); backticked
### Static-member / value-read refs (4750-4808) — kotlin IS in STATIC_MEMBER_LANGS (345-347) ### Static-member / value-read refs (4750-4808) — kotlin IS in STATIC_MEMBER_LANGS (345-347)
Called ONLY from the body walker (5218) — top-level/class-scope reads emit Called ONLY from the body walker (5218) — top-level/class-scope reads emit
nothing (hook-consumed property initializers doubly so). nothing — EXCEPT a property initializer, which the hook now walks through
visitFunctionBody under the property's own scope (§Properties).
`navigation_expression` ∈ MEMBER_ACCESS_TYPES (326). Mechanics: `navigation_expression` ∈ MEMBER_ACCESS_TYPES (326). Mechanics:
- callee-of-call skip (4772-4778): parent ∈ callTypes AND parent.namedChild(0) - callee-of-call skip (4772-4778): parent ∈ callTypes AND parent.namedChild(0)
@@ -850,12 +859,13 @@ unwrap/ungatedModes/addressOfOnly.
- Capture points: visitNode:990 (top-level/class-scope call args), - Capture points: visitNode:990 (top-level/class-scope call args),
visitFunctionBody:5137, scanFnRefSubtree (hook-consumed property visitFunctionBody:5137, scanFnRefSubtree (hook-consumed property
subtrees — `val x = register(::f)` captures via the inner subtrees — `val x = register(::f)` captures via the inner
value_arguments; **the scan halts at `lambda_literal` (610), so refs value_arguments; **the scan halts at `lambda_literal` (610)**, but the
inside `by lazy { }`/trailing lambdas under a hook-consumed property are hook's own initializer walk (§Properties) covers the same subtree with the
NOT captured**). **NOT captured anywhere: property/local initializer PROPERTY on the stack, so refs inside `by lazy { }`/trailing lambdas are
callable refs (`val m = ::caller`, `val bound = w::render`) — kotlin's captured there — a shallow `::ref` reachable by BOTH is emitted twice, once
dispatch has NO property_declaration/varinit key** (unlike SWIFT_SPEC — from the class and once from the property). **NOT captured anywhere:
do not borrow it). Pinned: torture emits exactly three function_refs — local initializer callable refs — kotlin's dispatch has NO
property_declaration/varinit key** (unlike SWIFT_SPEC — do not borrow it). Pinned: torture emits exactly three function_refs —
`topLevel` (definedHere), `OtherClass::handle`, `this.caller`. `topLevel` (definedHere), `OtherClass::handle`, `this.caller`.
- Flush gate (639-728): generated-file skip; `this.`-prefixed + - Flush gate (639-728): generated-file skip; `this.`-prefixed +
`::`-containing candidates always flush; bare names need definedHere `::`-containing candidates always flush; bare names need definedHere
@@ -1039,7 +1049,7 @@ unwrap/ungatedModes/addressOfOnly.
`Unit` / nullable / lambda return / `: T` generic leak; `expect fun` `Unit` / nullable / lambda return / `: T` generic leak; `expect fun`
(bodiless + dec) / `actual fun`; tailrec self-call in expression body; (bodiless + dec) / `actual fun`; tailrec self-call in expression body;
top-level `val`/`var`/`const val`/`by lazy {}` (constant/variable kinds, top-level `val`/`var`/`const val`/`by lazy {}` (constant/variable kinds,
NO initializer refs, NO capture inside the delegate lambda) + initializer + delegate refs attributed TO the property) +
**destructuring (`val (a,b)` → nothing, both scopes)** + next-line-getter **destructuring (`val (a,b)` → nothing, both scopes)** + next-line-getter
top-level `val` (getter calls → file/namespace); class with primary ctor top-level `val` (getter calls → file/namespace); class with primary ctor
(props invisible, defaults not walked), class-body val/var/computed (props invisible, defaults not walked), class-body val/var/computed
+42 -1
View File
@@ -42,6 +42,35 @@ function extractKotlinReturnType(node: SyntaxNode, source: string): string | und
return undefined; return undefined;
} }
/**
* A property's RHS: the named child right after the `=` token, plus a
* `property_delegate` (`by lazy { … }`). Everything else a
* `property_declaration` can hold is a declaration, not code — modifiers, the
* `val`/`var` keyword, the name+type, an extension receiver's type and type
* parameters, and `getter`/`setter` — so it stays unwalked. (Go's #693 fix
* walks the `value` field for the same reason; tree-sitter-kotlin exposes no
* fields at all, hence the `=` anchor.)
*/
function kotlinPropertyInitializers(node: SyntaxNode): SyntaxNode[] {
const out: SyntaxNode[] = [];
let afterEq = false;
for (let i = 0; i < node.childCount; i++) {
const c = node.child(i);
if (!c) continue;
if (!c.isNamed) {
if (c.type === '=') afterEq = true;
continue;
}
if (afterEq) {
out.push(c);
afterEq = false;
} else if (c.type === 'property_delegate') {
out.push(c);
}
}
return out;
}
/** Check if a node matches the `fun interface` misparse pattern */ /** Check if a node matches the `fun interface` misparse pattern */
function isFunInterfaceNode(node: SyntaxNode): boolean { function isFunInterfaceNode(node: SyntaxNode): boolean {
let hasFun = false; let hasFun = false;
@@ -126,7 +155,19 @@ export const kotlinExtractor: LanguageExtractor = {
const sig = typeNode const sig = typeNode
? `${isVal ? 'val' : 'var'} ${name}: ${getNodeText(typeNode, ctx.source)}` ? `${isVal ? 'val' : 'var'} ${name}: ${getNodeText(typeNode, ctx.source)}`
: undefined; : undefined;
ctx.createNode(kind, name, node, { signature: sig }); const created = ctx.createNode(kind, name, node, { signature: sig });
// Walk the initializer ATTRIBUTED to the declared symbol (#693, the Go
// fix, ported to Kotlin): the hook consumes this subtree, so without an
// explicit walk a lambda / SAM / object initializer
// (`private val cb = Runnable { target() }` — the idiomatic Android
// callback field) contributed NO call edge at all, and everything reached
// only through such a callback looked like it had no callers.
const inits = created ? kotlinPropertyInitializers(node) : [];
if (created && inits.length > 0) {
ctx.pushScope(created.id);
for (const init of inits) ctx.visitFunctionBody(init, created.id);
ctx.popScope();
}
return true; return true;
} }