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.
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
impactunder-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 toextractFunctionand gets its own node). For every other JS/TS initializer shape the gap is real — see the table.Repro, one file per language
Where each one goes wrong
property_declarationhook returnstrue; the dispatcher only runsscanFnRefSubtreeextractFieldmints the field node and stopsvariable_declarator'svaluenever walked; an anonymous class in a field initializer is never extracted at allextractVariable's walk runs with only the FILE onnodeStack; object literals are excluded from it outrightval/varhook returnstruevaluefield never walkedconst_item/static_itemrideextractVariable's generic fallbackvaluefield never walkedassignmentbranch mints the node and stopsrightfield never walkedHow much it costs, measured
Each number is one extractor arm (wasm) diffed against upstream
mainover a real tree, counting refs that appear/disappear/change owner:Parcelable.Creatorclasses)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
extractVariable's generic fallback mints a node for every directidentifierchild, soconst MAX: u32 = OTHER;producesMAXand a spuriousOTHER.variable_declarator(whose initializer is a bare child, with novaluefield),extractPropertywalks 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.