Procházet zdrojové kódy

fix(extraction): land upstream declaration initializer walks (#1511) (#1802)

Squash danusha2345's PR #1511 at d282f9e8 onto main 8c9c4761,
preserving its nine non-merge commits and main's existing Unreleased notes.
Calls in Kotlin, Java, TS/JS, Scala, Rust and Python declaration initializers
now retain the owner established by the upstream regression expectations.
Include the upstream CFML, dynamic-dispatch summary and viewer follow-ups.

Linux fail-to-pass validation (Node 22.19.0, rebuilt dist and native kernel):
- Before: TS load belonged to file:app.ts; Python/Kotlin/Scala/Rust calls
  vanished; Java lost the field-lambda, anonymous override and eager calls.
- After: all six languages PASS; 12 native/WASM LF/CRLF parity checks PASS.
- Focused initializer regressions: 10 passed with CODEGRAPH_KERNEL=0 and
  10 passed with the kernel enabled; Kotlin's grammar fallback is recorded.
- Related regression suites: 879 passed, 1 skipped across 15 test files.
- Evidence: /workspace/cg-1510-repro/before and /workspace/cg-1510-repro/after
  (combined test output: after/vitest.log).

Fixes #1510
Supersedes #1511

Co-authored-by: Colby McHenry <colbymchenry@users.noreply.github.com>
Co-authored-by: danusha2345 <ewidusoc498@gmail.com>
Colby Mchenry před 8 hodinami
rodič
revize
9181dd1ef3

+ 5 - 0
CHANGELOG.md

@@ -227,6 +227,11 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 #### Symbols, tests and the viewer
 
+- Calls inside declaration initializers in Kotlin, Java, TypeScript, JavaScript, Scala, Rust and Python now appear under the declaration that owns them, making callers and impact results more accurate after re-indexing with `codegraph index -f` (thanks @danusha2345; #1510, #1511).
+- Java fields initialized with anonymous classes now expose their methods and calls in the graph.
+- Kotlin property accessors, initialization blocks and destructuring declarations now retain their calls with the correct owner.
+- The viewer continues to count module-level initializer calls as top-level file activity in entry points and file screens.
+- `codegraph_explore` again lists a dynamic-dispatch link when the same two symbols are also joined by an ordinary call.
 - Rust unit structs (`struct Unit;`) and their trait implementation relationships now appear in the graph after re-indexing. (#1513, #1514)
 - Imports from Node built-ins or npm packages no longer connect to unrelated type members with matching names; re-index after upgrading to clear existing false dependencies. Thanks @ctype-lab. (#1537)
 

+ 266 - 0
__tests__/extraction.test.ts

@@ -1094,6 +1094,42 @@ const token = getTokenMp();
     );
     expect(call).toBeDefined();
   });
+
+  describe('initializer walk is scoped to the declared symbol (#693 for TS/JS)', () => {
+    const code = `
+const eager = load();
+const obj = { handler: () => target(), plain: target() };
+const list = [() => target()];
+export const exported = { handler: () => target() };
+`;
+    const callersOf = (name: string) => {
+      const result = extractFromSource('app.ts', code);
+      const byId = new Map(result.nodes.map((n) => [n.id, n]));
+      return result.unresolvedReferences
+        .filter((u) => u.referenceKind === 'calls' && u.referenceName === name)
+        .map((u) => byId.get(u.fromNodeId))
+        .map((n) => (n ? `${n.kind}:${n.name}` : '?'))
+        .sort();
+    };
+
+    it("a plain call initializer names the CONSTANT as caller, not the file", () => {
+      // The walk ran with only the file on the stack, so `load` recorded the
+      // file as its caller — useless for callers/impact.
+      expect(callersOf('load')).toEqual(['constant:eager']);
+    });
+
+    it('a non-exported object literal contributes calls (it was skipped outright)', () => {
+      // `exported`'s members are minted as their own function nodes, so its
+      // arrow's call comes from `handler`; the non-exported ones attribute to
+      // the declared constant.
+      expect(callersOf('target')).toEqual([
+        'constant:list',
+        'constant:obj',
+        'constant:obj',
+        'function:handler',
+      ]);
+    });
+  });
 });
 
 describe('File Node Extraction', () => {
@@ -1182,6 +1218,42 @@ class UserService:
     expect(classNode).toBeDefined();
     expect(classNode?.name).toBe('UserService');
   });
+
+  it('walks a module-level assignment initializer scoped to the name (#693 for Python)', () => {
+    // The assignment minted a node and stopped, so everything a module builds
+    // at import time — `app = FastAPI()`, `ENGINE = create_engine(url)` — was
+    // missing from the graph. A tuple target mints no symbol, so its
+    // right-hand side attributes to the enclosing scope instead of vanishing.
+    const code = `
+def target(): pass
+def compute(): return 1
+
+APP = compute()
+handler = lambda: target()
+MAPPING = {"a": compute()}
+first, second = compute(), target()
+
+class K:
+    ATTR = compute()
+`;
+    const result = extractFromSource('app.py', code);
+    const byId = new Map(result.nodes.map((n) => [n.id, n]));
+    const owners = result.unresolvedReferences
+      .filter((u) => u.referenceKind === 'calls')
+      .map((u) => {
+        const n = byId.get(u.fromNodeId);
+        return `${u.referenceName}<-${n ? `${n.kind}:${n.name}` : '?'}`;
+      })
+      .sort();
+    expect(owners).toEqual([
+      'compute<-class:K', // a class attribute still rides the class (no node of its own)
+      'compute<-file:app.py', // the tuple target mints nothing
+      'compute<-variable:APP',
+      'compute<-variable:MAPPING',
+      'target<-file:app.py',
+      'target<-variable:handler',
+    ]);
+  });
 });
 
 describe('Go Extraction', () => {
@@ -1507,6 +1579,26 @@ impl Counter {
     expect(implRefs).toHaveLength(0);
   });
 
+  it('walks a const/static initializer scoped to the declared symbol (#693 for Rust)', () => {
+    // The declaration minted a node and stopped, so a handler table, a
+    // lazily-built singleton or any computed const linked to nothing.
+    const code = `
+const LEN: usize = compute_len();
+static REGISTRY: Lazy<Cfg> = Lazy::new(|| build_cfg());
+`;
+    const result = extractFromSource('lib.rs', code);
+    const byId = new Map(result.nodes.map((n) => [n.id, n]));
+    const owner = (name: string) => {
+      const u = result.unresolvedReferences.find(
+        (r) => r.referenceKind === 'calls' && r.referenceName === name
+      );
+      const n = u ? byId.get(u.fromNodeId) : undefined;
+      return n ? `${n.kind}:${n.name}` : undefined;
+    };
+    expect(owner('compute_len')).toBe('variable:LEN');
+    expect(owner('build_cfg')).toBe('variable:REGISTRY');
+  });
+
   it('should extract union declarations and their impl edges', () => {
     const code = `
 pub union Reg {
@@ -1714,6 +1806,37 @@ public class Splitter {
     );
     expect(sepStart, 'override inside the lambda-returned anon class should be a method node').toBeDefined();
   });
+
+  it('walks a field initializer scoped to the field (#693 for Java)', () => {
+    // The dispatcher only scanned a field_declaration for function-as-value
+    // candidates, so a lambda or anonymous class holding the work — the
+    // Android listener idiom — contributed no call edge and `target` looked
+    // callerless.
+    const code = `
+package p;
+class T {
+    private final Runnable fieldLambda = () -> target();
+    private final Runnable anonClass = new Runnable() {
+        public void run() { target(); }
+    };
+    private final int eager = compute();
+    void directCall() { target(); }
+    private void target() {}
+    private static int compute() { return 1; }
+}
+`;
+    const result = extractFromSource('T.java', code);
+    const byId = new Map(result.nodes.map((n) => [n.id, n]));
+    const callersOf = (name: string) =>
+      result.unresolvedReferences
+        .filter((u) => u.referenceKind === 'calls' && u.referenceName === name)
+        .map((u) => byId.get(u.fromNodeId)?.name)
+        .sort();
+
+    // `run` is the anonymous class's override, itself extracted under the field.
+    expect(callersOf('target')).toEqual(['directCall', 'fieldLambda', 'run']);
+    expect(callersOf('compute')).toEqual(['eager']);
+  });
 });
 
 describe('C# Extraction', () => {
@@ -2317,6 +2440,120 @@ 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']);
+    });
+
+    it('an accessor body belongs to its property, written on either line', () => {
+      // `val x: T get() = …` nests the accessor UNDER the declaration; written
+      // on its own line the grammar makes it a following SIBLING instead. Both
+      // used to lose their calls (the nested one) or hand them to the enclosing
+      // class (the sibling); both now attribute to the property.
+      const src = `
+package p
+
+class C {
+    val sameLine: Int get() = compute()
+    val nextLine: Int
+        get() = compute()
+    var written: Int = 0
+        set(v) { store(v) }
+    private fun compute(): Int = 1
+    private fun store(v: Int) {}
+}
+`;
+      const result = extractFromSource('C.kt', src);
+      const byId = new Map(result.nodes.map((n) => [n.id, n]));
+      const ownersOf = (name: string) =>
+        result.unresolvedReferences
+          .filter((u) => u.referenceKind === 'calls' && u.referenceName === name)
+          .map((u) => {
+            const n = byId.get(u.fromNodeId);
+            return n ? `${n.kind}:${n.name}` : '?';
+          })
+          .sort();
+      expect(ownersOf('compute')).toEqual(['field:nextLine', 'field:sameLine']);
+      expect(ownersOf('store')).toEqual(['field:written']);
+    });
+
+    it('an `init` block and a destructuring RHS no longer vanish', () => {
+      // Both mint no symbol of their own, so the hook consumed them and their
+      // code disappeared entirely; they now attribute to the enclosing scope.
+      const src = `
+package p
+
+class C {
+    init { val q = initCall() }
+    val (a, b) = makePair()
+}
+
+val (t1, t2) = topMakePair()
+`;
+      const result = extractFromSource('C.kt', src);
+      const byId = new Map(result.nodes.map((n) => [n.id, n]));
+      const owner = (name: string) => {
+        const u = result.unresolvedReferences.find(
+          (r) => r.referenceKind === 'calls' && r.referenceName === name
+        );
+        const n = u ? byId.get(u.fromNodeId) : undefined;
+        return n ? `${n.kind}:${n.name}` : undefined;
+      };
+      expect(owner('initCall')).toBe('class:C');
+      expect(owner('makePair')).toBe('class:C');
+      expect(owner('topMakePair')).toBe('namespace:p');
+    });
+  });
 });
 
 describe('Dart Extraction', () => {
@@ -8294,6 +8531,35 @@ def processData(): Unit = {
       const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls');
       expect(calls.length).toBeGreaterThan(0);
     });
+
+    it('walks a val/var initializer scoped to the declared symbol (#693 for Scala)', () => {
+      // The val/var hook minted the node and returned true, so the dispatcher
+      // only scanned the subtree for function-as-value candidates — every call
+      // in an initializer was dropped, which on a `val`-heavy codebase
+      // (SpinalHDL, Akka wiring) is most of the wiring.
+      const code = `
+class C {
+  val fieldLambda: () => Unit = () => target()
+  val direct = target()
+  lazy val lazily = target()
+  private def target(): Unit = {}
+}
+
+object O {
+  val topLambda = () => hit()
+  def hit(): Unit = {}
+}
+`;
+      const result = extractFromSource('C.scala', code);
+      const byId = new Map(result.nodes.map((n) => [n.id, n]));
+      const callersOf = (name: string) =>
+        result.unresolvedReferences
+          .filter((u) => u.referenceKind === 'calls' && u.referenceName === name)
+          .map((u) => byId.get(u.fromNodeId)?.name)
+          .sort();
+      expect(callersOf('target')).toEqual(['direct', 'fieldLambda', 'lazily']);
+      expect(callersOf('hit')).toEqual(['topLambda']);
+    });
   });
 });
 

+ 9 - 0
__tests__/fixtures/kernel-parity/Torture.java

@@ -22,6 +22,15 @@ public class TortureService extends BaseService implements Runnable, AutoCloseab
   protected int count = 0;
   private final List<String> names;
   int packagePrivate, secondDeclarator;
+  /** Field initializers — walked scoped to the field (#693). */
+  private final Runnable fieldLambda = () -> helper(RETRY_LIMITS);
+  private final Runnable fieldAnonClass = new Runnable() {
+    @Override
+    public void run() {
+      helper(RETRY_LIMITS);
+    }
+  };
+  private final Runnable fieldMethodRef = TortureService::compute;
 
   /** Ctor javadoc. */
   public TortureService(List<String> names) {

+ 5 - 0
__tests__/fixtures/kernel-parity/torture.js

@@ -74,6 +74,11 @@ export default {
   },
 };
 
+// Initializer walks attributed to the declared symbol (#693). A plain call
+// leaked to the FILE node; a non-exported object literal was skipped outright.
+const eagerConfig = loadConfig();
+const handlerMap = { onSave: () => persist(eagerConfig), onLoad: loadConfig() };
+const lazyList = [() => persist(eagerConfig)];
 // --- CommonJS export assignments (#1675) -----------------------------------
 exports.getItems = async (req, res) => { res.json(await findItems()); };
 module.exports.deleteItem = function (req, res) { removeItem(req.params.id); res.end(); };

+ 24 - 0
__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
@@ -265,3 +272,20 @@ fun labeledLambda() {
 }
 
 fun whereClause(): Int where Int : Comparable<Int> = 1
+
+class AccessorK {
+    val sameLineGetter: Int get() = compute()
+    var sameLinePair: Int get() = compute()
+        set(v) { draw(v) }
+}
+
+class SiblingAccessorK {
+    var nextLine: Int = 0
+        get() = compute()
+        set(v) { draw(v) }
+    val (localA, localB) = makePair()
+    init {
+        val fromInit = compute()
+        register(fromInit)
+    }
+}

+ 5 - 0
__tests__/fixtures/kernel-parity/torture.py

@@ -48,6 +48,11 @@ def shadowed():
 handlers = {"recv": target_cb}
 callbacks = [target_cb, view]
 
+# Initializer walks attributed to the assigned name (#693).
+INIT_EAGER = helper()
+INIT_LAMBDA = lambda: target_cb()
+INIT_MAP = {"a": helper()}
+init_a, init_b = helper(), view()
 
 # --- call receivers (#1683) ---------------------------------------------------
 def bucket_chains(d, k, v):

+ 5 - 0
__tests__/fixtures/kernel-parity/torture.rs

@@ -285,6 +285,11 @@ fn mount() {
 
 routes![top_level_h];
 
+// Initializer walks attributed to the declared symbol (#693).
+const INIT_CONST: usize = compute_len();
+static INIT_LAZY: Lazy<Cfg> = Lazy::new(|| build_cfg());
+static INIT_ALIAS: fn() = free_fn;
+
 pub union Reg {
     pub raw: u32,
     pub halves: [u16; 2],

+ 7 - 0
__tests__/fixtures/kernel-parity/torture.scala

@@ -175,3 +175,10 @@ package object utilpkg {
   def pkgHelper(): Int = 1
   val pkgShared = 2
 }
+
+class InitWalk {
+  val initLambda: () => Unit = () => helperCall()
+  val initDirect = helperCall()
+  lazy val initLazy = process(1)
+  val initAnon = new Runnable { def run(): Unit = helperCall() }
+}

+ 3 - 1
__tests__/function-ref.test.ts

@@ -795,8 +795,10 @@ describe('Function-as-value capture (#756)', () => {
 
       // The DRF wiring: get_serializer_class → the imported serializer class,
       // via `return` — the issue's headline gap. The module-level registry
-      // dict rides the file node.
+      // dict rides BOTH the assigned name (the initializer walk, #693) and the
+      // file node (the dispatcher's own scan, which runs either way).
       expect(sourceNames(cg, fnRefEdgesInto(cg, 'OrgSerializerFull'))).toEqual([
+        'SERIALIZER_REGISTRY',
         'get_serializer_class',
         'views.py',
       ]);

+ 3 - 1
__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

+ 10 - 0
codegraph-kernel/src/java.rs

@@ -759,6 +759,16 @@ impl<'t> Walker<'t> {
                 if let Some(row) = row {
                     self.extract_decorators_for(node, row);
                     self.extract_type_annotations(node, row);
+                    // Walk the initializer ATTRIBUTED to the declared field
+                    // (#693, the Go fix): the dispatcher only fn-ref-scans this
+                    // subtree, so a lambda / method reference / anonymous class
+                    // in `private final Runnable r = () -> target();` emitted no
+                    // call edge at all.
+                    if let Some(value) = decl.child_by_field_name("value") {
+                        self.stack.push(Scope { row, kind: field_kind, name: name.clone() });
+                        self.visit_function_body(value);
+                        self.stack.pop();
+                    }
                 }
             }
         } else {

+ 147 - 24
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,66 @@ fn strip_js_ws(s: &str) -> String {
     s.chars().filter(|c| !is_js_space(*c)).collect()
 }
 
+/// A property's CODE children: the named child right after the `=` token, a
+/// `property_delegate` (`by lazy { … }`), and an accessor the grammar nested
+/// under the declaration (`val x: Int get() = compute()` — written on ONE line;
+/// an accessor on its own line parses as a SIBLING of the property and is not
+/// reachable from here). What stays unwalked is the declaration itself —
+/// modifiers, the `val`/`var` keyword, the name+type, and an extension
+/// receiver's type and type parameters. (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 matches!(c.kind(), "property_delegate" | "getter" | "setter") {
+            out.push(c);
+        }
+    }
+    out
+}
+
+/// Accessors written on their OWN line parse as SIBLINGS of the property, not
+/// as children of it (same-line ones nest — see property_initializers). Walking
+/// back over any accessors between us and the declaration finds the property an
+/// accessor belongs to; None when this accessor stands alone.
+fn accessor_owner<'t>(node: Node<'t>) -> Option<Node<'t>> {
+    let mut p = node.prev_named_sibling();
+    while let Some(n) = p {
+        if matches!(n.kind(), "getter" | "setter") {
+            p = n.prev_named_sibling();
+            continue;
+        }
+        return if n.kind() == "property_declaration" { Some(n) } else { None };
+    }
+    None
+}
+
+/// The sibling accessors that follow a property declaration, in source order.
+fn following_accessors<'t>(node: Node<'t>) -> Vec<Node<'t>> {
+    let mut out = Vec::new();
+    let mut n = node.next_named_sibling();
+    while let Some(c) = n {
+        if !matches!(c.kind(), "getter" | "setter") {
+            break;
+        }
+        out.push(c);
+        n = c.next_named_sibling();
+    }
+    out
+}
+
 struct Scope {
     row: u32,
     kind: &'static str,
@@ -583,25 +643,23 @@ impl<'t> Walker<'t> {
     // --- the visitNode hook (property branch ONLY — fun-interface recovery is
     // defer-shielded and not ported) ------------------------------------------------
 
-    fn try_visit_hook(&mut self, node: Node<'t>) -> bool {
-        if node.kind() != "property_declaration" {
-            return false;
-        }
+    /// A property's node kind, or None when the declaration mints no node at
+    /// all: destructuring, an unreadable name, or a local (inside a function
+    /// body / `init` block / lambda / accessor). Kind by enclosing scope — a
+    /// singleton `object` / `companion object` (and a top-level property) holds
+    /// SHARED values (`val`→constant, `var`→variable, the Scala-object rule; a
+    /// `const val` is just a val); a class/interface/enum instance `val`/`var`
+    /// is per-instance state → `field`.
+    fn property_kind(&self, node: Node<'t>) -> Option<&'static str> {
         let var_decl = (0..node.named_child_count())
             .filter_map(|i| node.named_child(i))
-            .find(|c| c.kind() == "variable_declaration");
-        let name_node = var_decl.and_then(|vd| {
-            (0..vd.named_child_count())
-                .filter_map(|i| vd.named_child(i))
-                .find(|c| c.kind() == "simple_identifier")
-        });
-        let Some(name_node) = name_node else { return false }; // destructuring → decline
-        let name = self.text(name_node).to_string();
-        if name.is_empty() {
-            return false;
+            .find(|c| c.kind() == "variable_declaration")?;
+        let name_node = (0..var_decl.named_child_count())
+            .filter_map(|i| var_decl.named_child(i))
+            .find(|c| c.kind() == "simple_identifier")?;
+        if self.text(name_node).is_empty() {
+            return None;
         }
-
-        // Scope walk up the parent chain — first match wins.
         let mut scope: &str = "const";
         let mut p = node.parent();
         while let Some(pn) = p {
@@ -624,24 +682,89 @@ impl<'t> Walker<'t> {
             p = pn.parent();
         }
         if scope == "local" {
-            return true; // a local — extract nothing, subtree still scanned
+            return None;
         }
-
         let binding = (0..node.named_child_count())
             .filter_map(|i| node.named_child(i))
             .find(|c| c.kind() == "binding_pattern_kind");
         let is_val = binding.map(|b| self.text(b) == "val").unwrap_or(false);
-        let kind: &'static str = if scope == "instance" {
+        Some(if scope == "instance" {
             "field"
         } else if is_val {
             "constant"
         } else {
             "variable"
+        })
+    }
+
+    fn try_visit_hook(&mut self, node: Node<'t>) -> bool {
+        // An own-line accessor already walked by its owning property below. The
+        // ownership test re-derives the property's kind rather than remembering
+        // it: a destructured or local declaration mints no node, so its
+        // accessors were NOT consumed and must keep falling through.
+        if matches!(node.kind(), "getter" | "setter") {
+            return accessor_owner(node)
+                .and_then(|owner| self.property_kind(owner))
+                .is_some();
+        }
+        if node.kind() != "property_declaration" {
+            return false;
+        }
+        let var_decl = (0..node.named_child_count())
+            .filter_map(|i| node.named_child(i))
+            .find(|c| c.kind() == "variable_declaration");
+        let name_node = var_decl.and_then(|vd| {
+            (0..vd.named_child_count())
+                .filter_map(|i| vd.named_child(i))
+                .find(|c| c.kind() == "simple_identifier")
+        });
+        // Destructuring (`val (a, b) = makePair()`): NEITHER arm mints a symbol
+        // for the destructured names — declining just routes the node to
+        // extractField/extractVariable, which both find nothing for kotlin and
+        // end in the same fn-ref scan. But the RHS is CODE, and it was vanishing
+        // whole. Consume the node here and walk it at the ENCLOSING scope (no
+        // symbol of its own to attribute to).
+        let Some(name_node) = name_node else {
+            for init in property_initializers(node) {
+                self.visit_function_body(init);
+            }
+            return true;
+        };
+        let name = self.text(name_node).to_string();
+        if name.is_empty() {
+            return false;
+        }
+        let Some(kind) = self.property_kind(node) else {
+            // A local — no node is minted, but the initializer is still code.
+            // Walk it at the ENCLOSING scope: an `init { }` block's
+            // `val q = load()` is the CLASS calling load, and it used to
+            // disappear entirely (only the block's bare statements survived).
+            for init in property_initializers(node) {
+                self.visit_function_body(init);
+            }
+            return true;
         };
         // 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.
+        // The property also OWNS any accessor written on its own line, which the
+        // grammar makes a following SIBLING rather than a child; those bodies
+        // used to attribute to the enclosing class.
+        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);
+            }
+            for acc in following_accessors(node) {
+                self.visit_function_body(acc);
+            }
+            self.stack.pop();
+        }
         true
     }
 

+ 30 - 7
codegraph-kernel/src/python.rs

@@ -475,14 +475,37 @@ impl<'t> Walker<'t> {
         let docstring = preceding_docstring(node, self.src);
         let left = node.child_by_field_name("left").or_else(|| node.named_child(0));
         let right = node.child_by_field_name("right").or_else(|| node.named_child(1));
-        let Some(left) = left else { return };
-        if !matches!(left.kind(), "identifier" | "constant") {
-            return;
+        let mut assigned: Option<(u32, String)> = None;
+        if let Some(left) = left {
+            if matches!(left.kind(), "identifier" | "constant") {
+                let name = self.text(left).to_string();
+                let signature = right.map(|r| util::init_signature(self.text(r)));
+                // No isConst hook ⇒ always `variable` (UPPER_CASE constants included).
+                let row = self.create_node(
+                    "variable",
+                    &name,
+                    node,
+                    Extra { docstring, signature, ..Extra::default() },
+                );
+                if let Some(row) = row {
+                    assigned = Some((row, name));
+                }
+            }
+        }
+        // Walk the initializer ATTRIBUTED to the assigned name (#693): a
+        // module-level `app = FastAPI()` / `handler = lambda: run()` dropped
+        // every call on the right-hand side. A tuple target mints no symbol, so
+        // its RHS is walked at the enclosing scope rather than lost.
+        if let Some(right) = right {
+            match assigned {
+                Some((row, name)) => {
+                    self.stack.push(Scope { row, kind: "variable", name });
+                    self.visit_function_body(right);
+                    self.stack.pop();
+                }
+                None => self.visit_function_body(right),
+            }
         }
-        let name = self.text(left).to_string();
-        let signature = right.map(|r| util::init_signature(self.text(r)));
-        // No isConst hook ⇒ always `variable` (UPPER_CASE constants included).
-        self.create_node("variable", &name, node, Extra { docstring, signature, ..Extra::default() });
     }
 
     fn extract_import(&mut self, node: Node<'t>) {

+ 23 - 1
codegraph-kernel/src/rustlang.rs

@@ -667,6 +667,8 @@ impl<'t> Walker<'t> {
     /// and the initializer value is never body-walked.
     fn extract_variable(&mut self, node: Node<'t>) {
         let docstring = preceding_docstring(node, self.src);
+        let name_field = node.child_by_field_name("name");
+        let mut declared: Option<(u32, String)> = None;
         for i in 0..node.named_child_count() {
             let Some(child) = node.named_child(i) else { continue };
             if child.kind() != "identifier" {
@@ -674,7 +676,7 @@ impl<'t> Walker<'t> {
             }
             let name = self.text(child).to_string();
             if !name.is_empty() {
-                self.create_node(
+                let row = self.create_node(
                     "variable",
                     &name,
                     child,
@@ -684,6 +686,26 @@ impl<'t> Walker<'t> {
                         ..Extra::default()
                     },
                 );
+                if let (Some(row), Some(nf)) = (row, name_field) {
+                    if child.start_byte() == nf.start_byte() {
+                        declared = Some((row, name));
+                    }
+                }
+            }
+        }
+        // Walk the initializer ATTRIBUTED to the declared symbol (#693):
+        // `const N: usize = compute()` and
+        // `static REGISTRY: Lazy<T> = Lazy::new(|| build())` dropped every call
+        // inside the initializer, so a handler table or a lazily-built
+        // singleton linked to nothing.
+        if let Some(value) = node.child_by_field_name("value") {
+            match declared {
+                Some((row, name)) => {
+                    self.stack.push(Scope { row, kind: "variable", name });
+                    self.visit_function_body(value);
+                    self.stack.pop();
+                }
+                None => self.visit_function_body(value),
             }
         }
     }

+ 12 - 0
codegraph-kernel/src/scala.rs

@@ -656,6 +656,18 @@ impl<'t> Walker<'t> {
                 if let (Some(row), Some(t)) = (created, type_node) {
                     self.emit_scala_type_refs(t, row);
                 }
+                // Walk the initializer ATTRIBUTED to the declared symbol
+                // (#693, the Go fix): the hook consumes this subtree and the
+                // dispatcher only fn-ref-scans it, so `val cb = () => target()`
+                // — and even a plain `val x = compute()` — emitted no call edge
+                // at all.
+                if let Some(row) = created {
+                    if let Some(value) = node.child_by_field_name("value") {
+                        self.stack.push(Scope { row, kind, name: name.clone() });
+                        self.visit_body(value);
+                        self.stack.pop();
+                    }
+                }
                 true
             }
             "enum_case_definitions" => {

+ 19 - 11
codegraph-kernel/src/tsjs/extractors.rs

@@ -483,18 +483,26 @@ impl<'t> Walker<'t> {
                 }
             }
 
-            // Walk the initializer for calls — except the object/store shapes
-            // whose members are extracted method-by-method below.
+            // Walk the initializer for calls, ATTRIBUTED to the declared symbol
+            // (#693) — except the object/store shapes whose members are
+            // extracted method-by-method below (walking those too would
+            // double-count each member arrow's calls). Before this the walk ran
+            // with only the FILE on the stack (`const cfg = load()` recorded the
+            // file as load's caller) and object literals were skipped outright.
+            let members_extracted_separately = extract_object_methods
+                || rtk_endpoints.is_some()
+                || pinia_setup.is_some()
+                || !store_collections.is_empty();
             if let Some(v) = value {
-                let vk = v.kind();
-                if vk != "object"
-                    && vk != "object_expression"
-                    && !(extract_object_methods && vk == "call_expression")
-                    && rtk_endpoints.is_none()
-                    && pinia_setup.is_none()
-                    && store_collections.is_empty()
-                {
-                    self.visit_function_body(v);
+                if !members_extracted_separately {
+                    match var_row {
+                        Some(row) => {
+                            self.stack.push(Scope { row, kind, name: name.clone() });
+                            self.visit_function_body(v);
+                            self.stack.pop();
+                        }
+                        None => self.visit_function_body(v),
+                    }
                 }
             }
 

+ 23 - 13
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

+ 25 - 6
src/db/queries.ts

@@ -2204,6 +2204,12 @@ export class QueryBuilder {
    * build script does its work on the way down the file. `instantiates` counts
    * the same way — `new Server(...)` at module scope is the same act.
    *
+   * A call made while initializing a module-level `variable` / `constant` —
+   * `const service = new Service()`, `app = FastAPI()` — is attributed to the
+   * declared name (#693), not to the file, so the file's own edges alone would
+   * miss most of what a real entry point runs. Those names are the file's
+   * top-level code too, so `tops` counts them alongside the file node.
+   *
    * Ranking multiplies the two things an entry point does: it runs (calls), and
    * it wires the project together (distinct other files its symbols reach). One
    * alone is misleading — a registration table makes hundreds of module-level
@@ -2216,12 +2222,25 @@ export class QueryBuilder {
     if (limit <= 0) return [];
     return this.db
       .prepare(
-        `WITH runs AS (
-             SELECT e.source AS id, COUNT(*) AS calls
-               FROM edges e
-               JOIN nodes n ON n.id = e.source
-              WHERE n.kind = 'file' AND e.kind IN ('calls', 'instantiates')
-           GROUP BY e.source
+        `WITH tops AS (
+             SELECT n.id AS file_id, n.id AS src
+               FROM nodes n
+              WHERE n.kind = 'file'
+             UNION ALL
+             SELECT c.source AS file_id, c.target AS src
+               FROM edges c
+               JOIN nodes f ON f.id = c.source
+               JOIN nodes v ON v.id = c.target
+              WHERE c.kind = 'contains'
+                AND f.kind = 'file'
+                AND v.kind IN ('variable', 'constant')
+         ),
+         runs AS (
+             SELECT t.file_id AS id, COUNT(*) AS calls
+               FROM tops t
+               JOIN edges e ON e.source = t.src
+              WHERE e.kind IN ('calls', 'instantiates')
+           GROUP BY t.file_id
          ),
          cand AS (
              SELECT r.id AS id, n.file_path AS fp, r.calls AS calls

+ 15 - 1
src/extraction/cfml-extractor.ts

@@ -356,6 +356,13 @@ export class CfmlExtractor {
         .filter((e) => e.kind === 'contains' && e.source === innerFileNodeId)
         .map((e) => e.target)
     );
+    // Snippet-top-level non-callables: `var x = …` locals of the enclosing
+    // function that the fragment-as-module parse mints as declarations.
+    const localVarIds = new Set(
+      result.nodes
+        .filter((n) => topLevelIds.has(n.id) && (n.kind === 'variable' || n.kind === 'constant'))
+        .map((n) => n.id)
+    );
     for (const node of result.nodes) {
       if (node.kind === 'file') continue;
       node.startLine += startLine;
@@ -385,7 +392,14 @@ export class CfmlExtractor {
       // top-level script in a .cfm template, or any statement directly in
       // the snippet body) attribute to the filtered-out snippet file node by
       // default — redirect those (and any genuinely unset ones) to parentId.
-      if ((!ref.fromNodeId || ref.fromNodeId === innerFileNodeId) && parentId) ref.fromNodeId = parentId;
+      // Same for a snippet-top-level `var x = helper()`: the inner extractor
+      // parses the fragment as a whole module, so it mints a variable node and
+      // attributes the initializer's calls to it — but this fragment is a
+      // FUNCTION BODY, so `x` is a local and `helper` is the enclosing
+      // function's callee. Snippet-top-level FUNCTIONS keep their own calls.
+      if ((!ref.fromNodeId || ref.fromNodeId === innerFileNodeId || localVarIds.has(ref.fromNodeId)) && parentId) {
+        ref.fromNodeId = parentId;
+      }
       this.unresolvedReferences.push(ref);
     }
     for (const error of result.errors) {

+ 140 - 25
src/extraction/languages/kotlin.ts

@@ -42,6 +42,99 @@ function extractKotlinReturnType(node: SyntaxNode, source: string): string | und
   return undefined;
 }
 
+/**
+ * A property's CODE children: the named child right after the `=` token, a
+ * `property_delegate` (`by lazy { … }`), and an accessor the grammar nested
+ * under the declaration (`val x: Int get() = compute()` — written on ONE line;
+ * an accessor on its own line parses as a SIBLING of the property and is not
+ * reachable from here). What stays unwalked is the declaration itself —
+ * modifiers, the `val`/`var` keyword, the name+type, and an extension
+ * receiver's type and type parameters. (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' || c.type === 'getter' || c.type === 'setter') {
+      out.push(c);
+    }
+  }
+  return out;
+}
+
+
+/**
+ * A property's node kind, or null when the declaration mints no node at all:
+ * destructuring (`val (a, b) = …`), an unreadable name, or a local (one inside
+ * a function body / `init` block / lambda / accessor). Kind by enclosing scope:
+ * a singleton `object` / `companion object` — and a top-level property — holds
+ * *shared* values, so `val`→`constant` and `var`→`variable` (the Scala-object
+ * rule; a `const val` is just a val). A `class`/`interface`/`enum` instance
+ * `val`/`var` is per-instance state → `field` (never a value-ref target, like a
+ * Java instance `final`).
+ */
+function kotlinPropertyKind(
+  node: SyntaxNode,
+  source: string
+): 'field' | 'constant' | 'variable' | null {
+  const varDecl = node.namedChildren.find((c) => c.type === 'variable_declaration');
+  const nameNode = varDecl?.namedChildren.find((c) => c.type === 'simple_identifier');
+  if (!nameNode || !getNodeText(nameNode, source)) return null;
+
+  let scope: 'local' | 'const' | 'instance' = 'const';
+  for (let p = node.parent; p; p = p.parent) {
+    const pt = p.type;
+    if (
+      pt === 'function_body' || pt === 'function_declaration' ||
+      pt === 'lambda_literal' || pt === 'anonymous_initializer' ||
+      pt === 'control_structure_body' || pt === 'getter' || pt === 'setter'
+    ) { scope = 'local'; break; }
+    if (pt === 'companion_object' || pt === 'object_declaration') { scope = 'const'; break; }
+    if (pt === 'class_declaration') { scope = 'instance'; break; }
+  }
+  if (scope === 'local') return null;
+
+  const binding = node.namedChildren.find((c) => c.type === 'binding_pattern_kind');
+  const isVal = binding != null && getNodeText(binding, source) === 'val';
+  return scope === 'instance' ? 'field' : isVal ? 'constant' : 'variable';
+}
+
+/**
+ * Accessors written on their OWN line parse as SIBLINGS of the property, not as
+ * children of it (same-line ones nest — see kotlinPropertyInitializers). Walking
+ * back over any accessors between us and the declaration finds the property an
+ * accessor belongs to; null when this accessor stands alone (a grammar
+ * accident, or an accessor on a destructured/local declaration).
+ */
+function kotlinAccessorOwner(node: SyntaxNode): SyntaxNode | null {
+  for (let p = node.previousNamedSibling; p; p = p.previousNamedSibling) {
+    if (p.type === 'getter' || p.type === 'setter') continue;
+    return p.type === 'property_declaration' ? p : null;
+  }
+  return null;
+}
+
+/** The sibling accessors that follow a property declaration, in source order. */
+function kotlinFollowingAccessors(node: SyntaxNode): SyntaxNode[] {
+  const out: SyntaxNode[] = [];
+  for (let n = node.nextNamedSibling; n; n = n.nextNamedSibling) {
+    if (n.type !== 'getter' && n.type !== 'setter') break;
+    out.push(n);
+  }
+  return out;
+}
+
 /** Check if a node matches the `fun interface` misparse pattern */
 function isFunInterfaceNode(node: SyntaxNode): boolean {
   let hasFun = false;
@@ -88,48 +181,70 @@ export const kotlinExtractor: LanguageExtractor = {
     // Kotlin properties (`val` / `var` / `const val`). The name nests as
     // property_declaration → variable_declaration → simple_identifier, which the
     // generic variable/field path can't read — so nothing was extracted before.
-    // Kind by enclosing scope: a singleton `object` / `companion object` (and a
-    // top-level property) holds *shared* values — `val`→`constant`,
-    // `var`→`variable` (the Scala-object rule; a `const val` is a `val`). A
-    // `class`/`interface`/`enum` instance `val`/`var` is per-instance state →
-    // `field` (never a value-ref target, like a Java instance `final`). A
-    // property inside a function body / `init` block / lambda is a local and is
-    // skipped entirely.
+    // Kind comes from kotlinPropertyKind.
     if (node.type === 'property_declaration') {
       const varDecl = node.namedChildren.find((c) => c.type === 'variable_declaration');
       const nameNode = varDecl?.namedChildren.find((c) => c.type === 'simple_identifier');
-      if (!nameNode) return false; // destructuring `val (a,b)` etc. — leave to default
+      // Destructuring (`val (a, b) = makePair()`): no symbol is minted for the
+      // destructured names either way — declining just routes the node to
+      // extractField/extractVariable, which both find nothing for Kotlin and
+      // end in the same fn-ref scan. But the RHS is CODE, and it was vanishing
+      // whole. Consume the node here and walk it at the ENCLOSING scope (there
+      // is no symbol of its own to attribute it to).
+      if (!nameNode) {
+        for (const init of kotlinPropertyInitializers(node)) ctx.visitFunctionBody(init, '');
+        return true;
+      }
       const name = getNodeText(nameNode, ctx.source);
       if (!name) return false;
 
-      // Walk to the nearest enclosing definition: a function body / init / lambda
-      // means it's a local; `object`/`companion object` is a constant scope; a
-      // `class_declaration` (covers class/interface/enum) is an instance scope.
-      let scope: 'local' | 'const' | 'instance' = 'const';
-      for (let p = node.parent; p; p = p.parent) {
-        const pt = p.type;
-        if (
-          pt === 'function_body' || pt === 'function_declaration' ||
-          pt === 'lambda_literal' || pt === 'anonymous_initializer' ||
-          pt === 'control_structure_body' || pt === 'getter' || pt === 'setter'
-        ) { scope = 'local'; break; }
-        if (pt === 'companion_object' || pt === 'object_declaration') { scope = 'const'; break; }
-        if (pt === 'class_declaration') { scope = 'instance'; break; }
+      const kind = kotlinPropertyKind(node, ctx.source);
+      if (kind == null) {
+        // A local — no node is minted, but the initializer is still code. Walk
+        // it at the ENCLOSING scope: an `init { }` block's `val q = load()` is
+        // the CLASS calling load, and it used to disappear entirely (only the
+        // block's bare statements survived).
+        for (const init of kotlinPropertyInitializers(node)) ctx.visitFunctionBody(init, '');
+        return true;
       }
-      if (scope === 'local') return true; // a local — don't extract
 
       const binding = node.namedChildren.find((c) => c.type === 'binding_pattern_kind');
       const isVal = binding != null && getNodeText(binding, ctx.source) === 'val';
-      const kind = scope === 'instance' ? 'field' : isVal ? 'constant' : 'variable';
-
       const typeNode = node.childForFieldName('type');
       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.
+      // The property also OWNS any accessor written on its own line, which the
+      // grammar makes a following SIBLING rather than a child; those bodies used
+      // to attribute to the enclosing class. Consumed here so the accessor
+      // branch below can skip them without any cross-node state.
+      const inits = created
+        ? [...kotlinPropertyInitializers(node), ...kotlinFollowingAccessors(node)]
+        : [];
+      if (created && inits.length > 0) {
+        ctx.pushScope(created.id);
+        for (const init of inits) ctx.visitFunctionBody(init, created.id);
+        ctx.popScope();
+      }
       return true;
     }
 
+    // An own-line accessor already walked by its owning property above. The
+    // ownership test re-derives the property's kind rather than remembering it:
+    // a destructured or local declaration mints no node, so its accessors were
+    // NOT consumed and must keep falling through to the normal recursion.
+    if (node.type === 'getter' || node.type === 'setter') {
+      const owner = kotlinAccessorOwner(node);
+      return owner != null && kotlinPropertyKind(owner, ctx.source) != null;
+    }
+
     // Handle Kotlin `fun interface` declarations.
     // Tree-sitter-kotlin doesn't support `fun interface` syntax (Kotlin 1.4+).
     // It produces two different misparse patterns:

+ 10 - 0
src/extraction/languages/scala.ts

@@ -166,6 +166,16 @@ export const scalaExtractor: LanguageExtractor = {
 
       const created = ctx.createNode(kind, name, node, { signature: sig, visibility: extractVisibility(node) });
       if (created && typeNode) emitScalaTypeRefs(typeNode, created.id, ctx, ctx.source);
+      // Walk the initializer ATTRIBUTED to the declared symbol (#693, the Go
+      // fix): the hook consumes this subtree and the dispatcher only scans it
+      // for function-as-value candidates, so `val cb = () => target()` — and
+      // even a plain `val x = compute()` — emitted no call edge at all.
+      const valueNode = node.childForFieldName('value');
+      if (created && valueNode) {
+        ctx.pushScope(created.id);
+        ctx.visitFunctionBody(valueNode, created.id);
+        ctx.popScope();
+      }
       return true;
     }
 

+ 66 - 14
src/extraction/tree-sitter.ts

@@ -2257,6 +2257,21 @@ export class TreeSitterExtractor {
           // and the language-aware path in `extractTypeAnnotations` descends
           // into that wrapper (#381).
           this.extractTypeAnnotations(node, fieldNode.id);
+          // Walk the initializer ATTRIBUTED to the declared field (#693, the
+          // Go fix; same shape as the TS/JS class-field walk above). The
+          // dispatcher only scanned this subtree for function-as-value
+          // candidates, so a lambda / method reference / anonymous class in
+          // `private final Runnable r = () -> target();` contributed NO call
+          // edge at all and `target` looked callerless. Keyed on the `value`
+          // FIELD, which only Java's `variable_declarator` carries — C#,
+          // VB.NET and PHP spell their initializer differently and are
+          // deliberately untouched here.
+          const valueNode = getChildByField(decl, 'value');
+          if (valueNode) {
+            this.nodeStack.push(fieldNode.id);
+            this.visitFunctionBody(valueNode, fieldNode.id);
+            this.nodeStack.pop();
+          }
         }
       }
     } else {
@@ -2818,19 +2833,24 @@ export class TreeSitterExtractor {
               storeCollections.push(objectOfFns);
             }
 
-            // Visit the initializer body for calls — EXCEPT object literals (their
-            // function-valued properties are extracted below) and the store-factory
-            // / createApi / store-collection call whose nested objects we extract
-            // method-by-method below (walking the whole call would re-visit those
-            // method arrows and mis-attribute their inner calls to the file scope).
-            if (valueNode &&
-                valueNode.type !== 'object' &&
-                valueNode.type !== 'object_expression' &&
-                !(extractObjectMethods && valueNode.type === 'call_expression') &&
-                !rtkEndpoints &&
-                !piniaSetup &&
-                storeCollections.length === 0) {
+            // Visit the initializer body for calls, ATTRIBUTED to the declared
+            // symbol (#693) — EXCEPT the shapes whose members are extracted
+            // one-by-one below (the store-factory / createApi / store-collection
+            // objects), where walking the whole initializer would re-visit each
+            // member arrow and double-count its calls.
+            //
+            // Two things were wrong here before. The walk ran with only the FILE
+            // on the stack, so `const cfg = load()` recorded the FILE as load's
+            // caller — the exact leak Go's #693 fixed. And an object literal was
+            // skipped outright, so `const obj = { handler: () => target() }`
+            // contributed nothing at all unless the const was exported (only then
+            // does extractObjectLiteralFunctions mint the members).
+            const membersExtractedSeparately =
+              extractObjectMethods || !!rtkEndpoints || !!piniaSetup || storeCollections.length > 0;
+            if (valueNode && !membersExtractedSeparately) {
+              if (varNode) this.nodeStack.push(varNode.id);
               this.visitFunctionBody(valueNode, '');
+              if (varNode) this.nodeStack.pop();
             }
 
             if (extractObjectMethods && objectOfFns) {
@@ -2855,6 +2875,7 @@ export class TreeSitterExtractor {
 
       // Ruby constant assignments (`MAX = 3`) have a `constant`-typed LHS, not
       // `identifier`; without this they were never extracted as symbols at all.
+      let assigned: Node | null = null;
       if (left && (left.type === 'identifier' || left.type === 'constant')) {
         const name = getNodeText(left, this.source);
         // Skip if name starts with lowercase and looks like a function call result
@@ -2862,11 +2883,23 @@ export class TreeSitterExtractor {
         const initValue = right ? getNodeText(right, this.source).slice(0, 100) : undefined;
         const initSignature = initValue ? `= ${initValue}${initValue.length >= 100 ? '...' : ''}` : undefined;
 
-        this.createNode(kind, name, node, {
+        assigned = this.createNode(kind, name, node, {
           docstring,
           signature: initSignature,
         });
       }
+      // Walk the initializer ATTRIBUTED to the assigned name (#693). A
+      // module-level `app = FastAPI()` / `ENGINE = create_engine(url)` /
+      // `handler = lambda: run()` dropped every call on the right-hand side, so
+      // whatever the module builds at import time linked to nothing. A tuple
+      // target (`a, b = f(), g()`) mints no symbol, so its RHS is walked at the
+      // enclosing scope rather than lost. Python only: Ruby shares this branch
+      // and gets its own turn.
+      if (this.language === 'python' && right) {
+        if (assigned) this.nodeStack.push(assigned.id);
+        this.visitFunctionBody(right, '');
+        if (assigned) this.nodeStack.pop();
+      }
     } else if (this.language === 'go') {
       // Go: var_declaration, short_var_declaration, const_declaration
       // These can have multiple identifiers on the left
@@ -3019,6 +3052,8 @@ export class TreeSitterExtractor {
     } else {
       // Generic fallback for other languages
       // Try to find identifier children
+      const nameField = getChildByField(node, 'name');
+      let declared: Node | null = null;
       for (let i = 0; i < node.namedChildCount; i++) {
         const child = node.namedChild(i);
         if (child?.type === 'identifier' || child?.type === 'variable_declarator') {
@@ -3027,13 +3062,30 @@ export class TreeSitterExtractor {
             : extractName(child, this.source, this.extractor);
 
           if (name && name !== '<anonymous>') {
-            this.createNode(kind, name, child, {
+            const created = this.createNode(kind, name, child, {
               docstring,
               isExported,
             });
+            if (created && nameField && child.startIndex === nameField.startIndex) {
+              declared = created;
+            }
           }
         }
       }
+      // Walk the initializer ATTRIBUTED to the declared symbol (#693). Rust
+      // only for now: `const N: usize = compute()` and
+      // `static REGISTRY: Lazy<T> = Lazy::new(|| build())` dropped every call
+      // inside the initializer, so a handler table or a lazily-built singleton
+      // linked to nothing. The other languages sharing this fallback spell
+      // their initializer differently and get their own turn.
+      if (this.language === 'rust') {
+        const valueNode = getChildByField(node, 'value');
+        if (valueNode) {
+          if (declared) this.nodeStack.push(declared.id);
+          this.visitFunctionBody(valueNode, '');
+          if (declared) this.nodeStack.pop();
+        }
+      }
     }
   }
 

+ 9 - 1
src/graph/named-symbol-flow.ts

@@ -277,8 +277,16 @@ export function resolveNamedTokens(
   const segPool = new Set<string>();
   for (const t of tokens) for (const s of t.toLowerCase().split(/::|\./)) if (s) segPool.add(s);
 
+  // RAW edges, not getCallers/getCallees: those return one row per NEIGHBOUR
+  // (the #1086 de-dup), so when a pair is joined by BOTH a static and a
+  // synthesized edge the static one wins and the synthesized one becomes
+  // invisible — which is exactly what happens once a thunk's `dispatch(x)`
+  // is walked statically. The question here is about the graph, not about
+  // callers, so ask the edges directly.
   const hasHeuristicEdge = (id: string): boolean =>
-    [...cg.getCallers(id), ...cg.getCallees(id)].some(({ edge }) => edge.provenance === 'heuristic');
+    [...cg.getIncomingEdges(id), ...cg.getOutgoingEdges(id)].some(
+      (e) => e.provenance === 'heuristic'
+    );
 
   for (const t of tokens) {
     const hits = findAllSymbols(cg, t).nodes;

+ 9 - 2
src/mcp/tools.ts

@@ -2760,9 +2760,16 @@ export class ToolHandler {
         const synthSeen = new Set<string>();
         for (const n of [...named.values(), ...dynNamed.values()]) {
           if (synthLines.length >= 6) break;
-          for (const { node: other, edge } of [...cg.getCallers(n.id), ...cg.getCallees(n.id)]) {
+          // RAW edges for the same reason as hasHeuristicEdge above — a static
+          // edge over the same pair hides the synthesized one from getCallers.
+          const incident = [...cg.getIncomingEdges(n.id), ...cg.getOutgoingEdges(n.id)];
+          for (const edge of incident) {
             if (synthLines.length >= 6) break;
-            if (edge.provenance !== 'heuristic' || other.id === n.id) continue;
+            if (edge.provenance !== 'heuristic') continue;
+            const otherId = edge.source === n.id ? edge.target : edge.source;
+            if (otherId === n.id) continue;
+            const other = cg.getNode(otherId);
+            if (!other) continue;
             if (skipInChain && skipInChain(edge)) continue;
             const src = edge.source === n.id ? n : other;
             const tgt = edge.source === n.id ? other : n;

+ 14 - 1
src/ui-server/api/file.ts

@@ -66,6 +66,7 @@ export function buildFile(cg: CodeGraph, projectRoot: string, requested: string)
   const nodes = cg.getNodesInFile(storedPath);
   const nodeIds = nodes.map((n) => n.id);
   const inThisFile = new Set(nodeIds);
+  const nodeKindById = new Map(nodes.map((n) => [n.id, n.kind]));
   const fileNode = nodes.find((n) => n.kind === 'file') ?? null;
 
   // ---------------------------------------------------------------------------
@@ -106,8 +107,20 @@ export function buildFile(cg: CodeGraph, projectRoot: string, requested: string)
   // the same signal `/api/entrypoints` ranks on. It is worth a line on this
   // screen because the outline cannot show it: top-level code belongs to no
   // symbol, so the only way to read it is to open the file node itself.
+  // A call made while initializing a module-level variable or constant is
+  // attributed to that name (#693), so those names are top-level code too and
+  // are counted with the file — the same set `getTopCallingFiles` ranks on.
+  const moduleLevelValueIds = fileNode
+    ? cg
+        .getOutgoingEdgesFrom([fileNode.id], ['contains'])
+        .map((e) => e.target)
+        .filter((id) => {
+          const kind = nodeKindById.get(id);
+          return kind === 'variable' || kind === 'constant';
+        })
+    : [];
   const topLevelEdges = fileNode
-    ? cg.getOutgoingEdgesFrom([fileNode.id], ['calls', 'instantiates'])
+    ? cg.getOutgoingEdgesFrom([fileNode.id, ...moduleLevelValueIds], ['calls', 'instantiates'])
     : [];
 
   return {