Skip to content

Declaration initializers are still unwalked (or unscoped) outside Go — #693 was fixed for Go only #1510

Description

@danusha2345

Summary

#693 ("CodeGraph does not index call edges from anonymous/lambda functions") was fixed for Go only, by #744. The same gap is still open in Kotlin, Java, TypeScript/JavaScript, Scala, Rust and Python — and in most of them it is worse than in Go: the calls are not merely attributed to the file node, they never reach the graph at all.

The consequence is the one #693 described: a function reached only through a declaration's initializer looks like it has no callers, and impact under-reports its blast radius. On Android/MSDK code, on Scala, and on any Python module that wires itself up at import time, that is most of the wiring.

One correction to #693's closing note, which says JS/TS didn't have this gap: that holds only for const f = () => … (an arrow value delegates to extractFunction and gets its own node). For every other JS/TS initializer shape the gap is real — see the table.

Repro, one file per language

class C {
    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
    val sameLine: Int get() = compute()                       // no caller
    init { val cfg = load() }                                 // `load` vanishes
    val (a, b) = makePair()                                   // `makePair` vanishes
}
object Holder { val cb: () -> Unit = { hit() } }              // no caller
class T {
    private final Runnable fieldLambda = () -> target();                      // no caller
    private final Runnable l = new LocationListener() { … target() … };       // class + method invisible
    private final int eager = compute();                                      // no caller
}
const cfg = load();                                  // caller recorded as the FILE node
const obj = { handler: () => target() };             // nothing at all — no member node, no edge
const list = [() => target()];                       // caller recorded as the FILE node
export const ok = { handler: () => target() };       // works (exported object-of-functions path)
class C {
  val fieldLambda: () => Unit = () => target()   // no caller
  val direct = compute()                         // no caller
  lazy val lazily = compute()                    // no caller
}
const LEN: usize = compute_len();                        // no caller
static REGISTRY: Lazy<Cfg> = Lazy::new(|| build_cfg());  // no caller
APP = compute()                        # no caller
handler = lambda: target()             # no caller
MAPPING = {"a": compute()}             # no caller
first, second = compute(), other()     # no caller, and no symbol either

Where each one goes wrong

language mechanism result
Kotlin the property_declaration hook returns true; the dispatcher only runs scanFnRefSubtree initializer, delegate and accessor bodies never walked
Java extractField mints the field node and stops variable_declarator's value never walked; an anonymous class in a field initializer is never extracted at all
TS/JS extractVariable's walk runs with only the FILE on nodeStack; object literals are excluded from it outright calls attributed to the file; non-exported object literals contribute nothing
Scala the val/var hook returns true value field never walked
Rust const_item/static_item ride extractVariable's generic fallback value field never walked
Python the assignment branch mints the node and stops right field never walked

How much it costs, measured

Each number is one extractor arm (wasm) diffed against upstream main over a real tree, counting refs that appear/disappear/change owner:

language corpus lost re-attributed new
Kotlin 113-file Android app (MSDK callbacks) 0 0 +368
Java 409-file Android SDK sample tree 0 0 +651 (+21 nodes: anonymous listener/Parcelable.Creator classes)
TS/JS 499 files across three projects 0 780 (file → declaring constant) +228
Scala 32-file SpinalHDL project 0 0 +812
Rust 282 files across three projects 0 0 +86
Python 799 files across three projects 0 0 +3057

Node and edge counts are unchanged everywhere except Java (+21 nodes, the anonymous classes that were previously invisible).

Two smaller defects found while measuring, not part of this family

  • Rust phantom node. extractVariable's generic fallback mints a node for every direct identifier child, so const MAX: u32 = OTHER; produces MAX and a spurious OTHER.
  • C# is wider than a field initializer. Beyond variable_declarator (whose initializer is a bare child, with no value field), extractProperty walks neither the accessor bodies, nor => expr, nor { get; } = …. For decompiled/reverse-engineered C#, where logic often lives in property getters, that is the larger share of the recall loss.

Happy to split either of those out into its own issue.

Fix

PR incoming — Kotlin, Java, TS/JS, Scala, Rust and Python, both extractor arms (TS + the Rust kernel), kernel parity preserved per language. C# deliberately left out; it needs its own shape and its own validation.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions