diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a5c7d524..1ec097c90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) - 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) +- 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) ## [1.5.0] - 2026-07-21 diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index 784023952..15cff153a 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -1940,6 +1940,61 @@ class Bar { const cls = result.nodes.find((n) => n.kind === 'class' && n.name === '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', () => { diff --git a/__tests__/fixtures/kernel-parity/torture.kt b/__tests__/fixtures/kernel-parity/torture.kt index 130611c81..e993d5840 100644 --- a/__tests__/fixtures/kernel-parity/torture.kt +++ b/__tests__/fixtures/kernel-parity/torture.kt @@ -50,6 +50,13 @@ val topDelegated by lazy { WidgetK(1) } val (destA, destB) = makePair() val withGetter: Int 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()) { val area: Int = size * size diff --git a/__tests__/kernel-kotlin-parity.test.ts b/__tests__/kernel-kotlin-parity.test.ts index 4e4540882..649c44455 100644 --- a/__tests__/kernel-kotlin-parity.test.ts +++ b/__tests__/kernel-kotlin-parity.test.ts @@ -5,7 +5,9 @@ * compiled from the vendored fwcd 0.3.8 C sources, the arc's first * vendored-grammar-C language) produces the SAME ExtractionResult as the * 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 * fallback, expect/actual → node DECORATORS (the KMP synthesizer feed), * the bodiless-vs-bodied class header asymmetry, comment-glued diff --git a/codegraph-kernel/src/kotlin.rs b/codegraph-kernel/src/kotlin.rs index ee47d8758..6507affd4 100644 --- a/codegraph-kernel/src/kotlin.rs +++ b/codegraph-kernel/src/kotlin.rs @@ -9,10 +9,10 @@ //! is source-order dependent) and extractModifiers (expect/actual platform //! modifiers → the node DECORATORS wire field, on every created node — the //! KMP synthesizer's input). Preserved on purpose: the FIELD_COUNT-0 dead -//! cluster (no signatures, ZERO type-annotation refs), hook-consumed property -//! initializers emitting nothing, the bodiless-class header re-walk asymmetry, -//! enum-entry bodies being invisible, KDoc (`multiline_comment`) never being -//! a docstring AND chain-breaking, comment-gluing into import/package extents, +//! cluster (no signatures, ZERO type-annotation refs), the bodiless-class +//! header re-walk asymmetry, enum-entry bodies being invisible, KDoc +//! (`multiline_comment`) never being a docstring AND chain-breaking, +//! comment-gluing into import/package extents, //! `@Anno(args)` emitting nothing while `@Anno` emits decorates, zero //! instantiates refs (constructors are capitalized `calls`), the qualified- //! 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() } +/// 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> { + let mut out: Vec> = 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 { row: u32, kind: &'static str, @@ -641,7 +669,18 @@ impl<'t> Walker<'t> { // The `type`-field signature read is dead (zero fields) → signature // undefined; NO docstring/visibility/isStatic — the modifiers merge in // 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 } diff --git a/docs/design/kotlin-kernel-port-checklist.md b/docs/design/kotlin-kernel-port-checklist.md index 1d5fd3e58..fcbdcd9c8 100644 --- a/docs/design/kotlin-kernel-port-checklist.md +++ b/docs/design/kotlin-kernel-port-checklist.md @@ -278,10 +278,18 @@ Hooks PRESENT (port each exactly): but createNode's extractModifiers merge still runs, so `expect val` / `actual val` DO get decorators. Return true → the dispatcher runs `scanFnRefSubtree(node, 0)` (capture-only, halts at nested - function/lambda types) and NEVER descends → **property initializers - emit NO calls/instantiates refs anywhere** (`val SHARED = WidgetK(0)` - → nothing; `by lazy { compute() }` → nothing, the scan halts at the - lambda_literal). Consequences pinned in `extract-torture.txt`. + function/lambda types) and never descends on its own. **The hook itself + then walks the property's RHS under the property's scope** — the named + child after the `=` token plus a `property_delegate` — via + `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 3. **fun-interface misparse recovery (:145-214)** (ERROR/ 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`) | | `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 | -| `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 | | 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) | @@ -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) 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: - 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), visitFunctionBody:5137, scanFnRefSubtree (hook-consumed property subtrees — `val x = register(::f)` captures via the inner - value_arguments; **the scan halts at `lambda_literal` (610), so refs - inside `by lazy { }`/trailing lambdas under a hook-consumed property are - NOT captured**). **NOT captured anywhere: property/local initializer - callable refs (`val m = ::caller`, `val bound = w::render`) — kotlin's - dispatch has NO property_declaration/varinit key** (unlike SWIFT_SPEC — - do not borrow it). Pinned: torture emits exactly three function_refs — + value_arguments; **the scan halts at `lambda_literal` (610)**, but the + hook's own initializer walk (§Properties) covers the same subtree with the + PROPERTY on the stack, so refs inside `by lazy { }`/trailing lambdas are + captured there — a shallow `::ref` reachable by BOTH is emitted twice, once + from the class and once from the property). **NOT captured anywhere: + 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`. - Flush gate (639-728): generated-file skip; `this.`-prefixed + `::`-containing candidates always flush; bare names need definedHere @@ -1039,7 +1049,7 @@ unwrap/ungatedModes/addressOfOnly. `Unit` / nullable / lambda return / `: T` generic leak; `expect fun` (bodiless + dec) / `actual fun`; tailrec self-call in expression body; 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 top-level `val` (getter calls → file/namespace); class with primary ctor (props invisible, defaults not walked), class-body val/var/computed diff --git a/src/extraction/languages/kotlin.ts b/src/extraction/languages/kotlin.ts index 5e3c4e2fa..5b79d2447 100644 --- a/src/extraction/languages/kotlin.ts +++ b/src/extraction/languages/kotlin.ts @@ -42,6 +42,35 @@ function extractKotlinReturnType(node: SyntaxNode, source: string): string | und 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 */ function isFunInterfaceNode(node: SyntaxNode): boolean { let hasFun = false; @@ -126,7 +155,19 @@ export const kotlinExtractor: LanguageExtractor = { const sig = typeNode ? `${isVal ? 'val' : 'var'} ${name}: ${getNodeText(typeNode, ctx.source)}` : 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; }