Skip to content

fix(go): attribute calls inside top-level closures to the var, not the file (#693) - #744

Merged
colbymchenry merged 1 commit into
mainfrom
fix/693-go-closure-call-attribution
Jun 9, 2026
Merged

colbymchenry merged 1 commit into
mainfrom
fix/693-go-closure-call-attribution

Conversation

@colbymchenry

Copy link
Copy Markdown
Owner

Fixes #693.

Problem

A function called only from inside an anonymous closure at package level — a cobra RunE: func(...) {...} handler, a goroutine literal, or a callback closure stored in a var — showed up with no meaningful caller. The call leaked to the file node, so codegraph_callers / codegraph_impact couldn't answer "what calls Wire?".

Root cause: Go's top-level var-initializer walk ran with an empty scope, so a Wire() call inside var rootCmd = &cobra.Command{ RunE: func(){ Wire() } } attributed to the file. This is a Go-specific gap — JS/TS already turn an arrow-in-const (const handler = () => {…}) and object-arrow methods into named nodes whose calls attribute correctly.

Fix

Scope the Go top-level var/const initializer walk to the declared symbol, so a call nested in any func_literal initializer — struct field (RunE:), slice/map element, or nested closure — attributes to the enclosing var instead of the file. callers(Wire) now returns rootCmd (variable).

EXTRACTION_VERSION 3→4 so existing Go indexes get a re-index hint.

Validation

  • Reproduced the exact reporter scenario; verified callers(Wire) → rootCmd after the fix. Covered every closure shape (struct-field, plain var, function-arg, nested, in-function control) — top-level ones now attribute to a var, in-function ones still to the function.
  • Real-repo A/B on cli/cli (858 Go files, the canonical cobra app), baseline build vs this:
    • nodes 16,319 = 16,319, edges 66,395 = 66,395 — no explosion, no edges added/lost
    • file-level dependents fingerprint byte-identical — no cross-file regression
    • exactly 62 top-level-closure calls moved from file-attributed → var-attributed (the fix, on real code)
  • Full suite green (1301 passed, 2 skipped; one pre-existing flaky daemon timing test times out only under full-suite CPU load and passes in 358ms in isolation — unrelated to this extractVariable change).
  • 1 test added.

🤖 Generated with Claude Code

…e file (#693)

A function called only from an anonymous func_literal at package level — a
cobra `RunE: func(){…}` handler, a goroutine literal, a callback closure
stored in a `var` — had its call leak to the FILE node, because the Go
var-initializer walk ran with an empty scope. So `callers`/`impact` showed
the function with a file (or no meaningful) caller, unlike JS/TS where an
arrow-in-const becomes a named node whose calls attribute correctly.

Scope the Go top-level var/const initializer walk to the declared symbol, so
a call nested in any func_literal initializer (struct field, slice/map,
nested closure) attributes to the enclosing var. EXTRACTION_VERSION 3->4
(re-index to pick up the corrected attribution).

Validated on cli/cli (858 Go files): node/edge counts identical, file-level
dependents byte-identical (no regression), and 62 top-level-closure calls
correctly moved from file-attributed to var-attributed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@colbymchenry
colbymchenry merged commit 5b3f5e3 into main Jun 9, 2026
@colbymchenry
colbymchenry deleted the fix/693-go-closure-call-attribution branch June 9, 2026 02:05
bompus pushed a commit to bompus/codegraph that referenced this pull request Sep 5, 2026
…lbymchenry#693 for Kotlin)

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

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

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

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

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

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

Measured on a 113-file Android/Kotlin app: strictly additive, 0 lost nodes /
edges / refs, +7 nodes (anonymous-object overrides that were invisible),
+109 edges (+47 calls, +35 instantiates, +20 references), and the real case
that motivated this — a `CameraFrameListener` field — now shows up as a
caller of the method it invokes.
bompus pushed a commit to bompus/codegraph that referenced this pull request Sep 5, 2026
…ry#693 for Java)

`extractField` minted the field node and stopped; the dispatcher then only
scanned the `field_declaration` subtree for function-as-value candidates. So
a field initializer's code was never walked:

    private final Runnable fieldLambda = () -> target();          // no caller
    private final Runnable l = new LocationListener() { … };       // invisible
    private final int eager = compute();                           // no caller

The anonymous-class form lost more than edges — the class and its overrides
were never extracted at all, which is how `Parcelable.Creator` and every
Android listener field is written.

Fix: walk the declarator's `value` through visitFunctionBody with the field
pushed on the scope stack — Go's colbymchenry#693/colbymchenry#744 fix, and the same shape as the
TS/JS class-field walk already sitting in the methodTypes branch (colbymchenry#808).
`extractField` is shared, but the walk is keyed on the `value` FIELD, which
only Java's `variable_declarator` carries: C# (bare child), VB.NET
(`initializer`) and PHP (`default_value`, separate branch) are untouched and
stay for their own turn.

Kernel and TS extractor move together, so kernel parity stays byte-identical.

Measured on the 409-file DJI MSDK v5 UX SDK (Java): strictly additive, 0 lost
nodes / edges / refs, +21 nodes (anonymous listener and Creator classes with
their overrides), +21 edges, +651 refs, 409/409 files still byte-parity
between the two arms.
bompus pushed a commit to bompus/codegraph that referenced this pull request Sep 5, 2026
…ymchenry#693 for TS/JS)

Two defects in one place — extractVariable's JS-family branch.

1. The initializer walk ran with only the FILE on the node stack, so
   `const cfg = loadConfig()` recorded the FILE as loadConfig's caller. That is
   literally the leak colbymchenry#693 described and colbymchenry#744 fixed for Go; the closing note
   there said "JS/TS didn't have this gap", which holds only for the
   `const f = () => …` shape (an arrow value delegates to extractFunction and
   gets its own node).

2. Object literals were excluded from the walk outright, on the grounds that
   their function-valued members are extracted individually below — but that
   only happens for EXPORTED consts. `const obj = { handler: () => target() }`
   therefore contributed nothing at all: no member node, and no call edge to
   anything.

Fix: walk the initializer with the declared symbol pushed on the stack, and
skip only the shapes whose members really are extracted one-by-one (exported
object-of-functions, RTK endpoints, Pinia setup, Vue store collections) —
walking those too would double-count each member arrow's calls.

Two follow-on repairs the change surfaced:

- CFML `<cfscript>` bodies are delegated to a separate extractor as if they
  were a whole module, so a `var x = helper()` local inside a `<cffunction>`
  minted a top-level variable node and the walk attributed `helper` to it.
  Those snippet-top-level non-callables are locals of the enclosing function;
  their refs now redirect to it, like the snippet's file-attributed refs
  already did.
- codegraph_explore stopped listing a synthesized dynamic-dispatch link once
  the same pair also had a static call edge: it asked getCallers/getCallees,
  which return one row per NEIGHBOUR (the colbymchenry#1086 de-dup), so the static edge
  hid the heuristic one. It now asks the edges directly. This is exactly the
  RTK thunk case — `dispatch(innerThunk(n))` inside a thunk initializer is now
  a real static edge, which is the point, but the synthesized hop must still
  show up in the summary.

Measured on three independent TypeScript trees (codegraph's own src/, evcc-ng,
gv-grx — 499 files): 0 lost nodes/edges/refs, 780 refs re-attributed from the
file node to the declaring constant, 228 genuinely new refs from object
literals, node and edge counts unchanged. Kernel parity holds (324/324
non-deferred files byte-identical).
bompus pushed a commit to bompus/codegraph that referenced this pull request Sep 5, 2026
…olbymchenry#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 and the initializer's
code was never walked:

    val fieldLambda: () => Unit = () => target()   // no caller
    val direct = target()                          // no caller
    lazy val lazily = compute()                    // no caller
    val anon = new Runnable { def run() = target() }  // no caller

Scala puts almost everything in a val, so this is not an edge case: on a
32-file SpinalHDL project the graph was missing 812 references.

Fix: walk the `value` field through visitFunctionBody with the declared symbol
pushed on the scope stack — the same shape as Go's colbymchenry#693/colbymchenry#744 fix, which the
grammar supports directly here (`val_definition` exposes `value`).

Kernel and TS extractor move together; kernel parity holds.

Measured on that SpinalHDL project: 0 lost refs, 0 re-attributed, +812 new,
node and edge counts unchanged (824/792), 32/32 files byte-parity between the
two arms.
bompus pushed a commit to bompus/codegraph that referenced this pull request Sep 5, 2026
…he name (colbymchenry#693 for Python)

The assignment branch minted the node and stopped, so every call on the
right-hand side was missing from the graph:

    APP = compute()                    # no caller
    handler = lambda: target()         # no caller
    MAPPING = {"a": compute()}         # no caller
    first, second = compute(), f()     # no caller, and no symbol either

That is everything a module wires up at import time — `app = FastAPI()`,
`ENGINE = create_engine(url)`, `router = build_router()`, handler registries —
so whatever those build looked unreferenced.

Fix: walk the `right` field through visitFunctionBody with the assigned name
pushed on the scope stack. A tuple target mints no symbol, so its RHS is walked
at the enclosing scope rather than lost. Gated to Python — Ruby shares this
branch and gets its own turn.

Class attributes are untouched: they never reach this branch (the dispatcher's
class-scope gate excludes them), so their calls keep riding the class node.
Giving them symbols of their own is a separate question — there are no nodes
for them today at all.

A function-as-value in an initializer (`REGISTRY = {"org": Serializer}`) now
produces a reference from BOTH the assigned name and the file node, because the
dispatcher's own scan runs either way. That is the shape Go's colbymchenry#744 already
ships — verified against the Go arm on the same input — so the two languages
stay consistent; the function-ref test's expectation is updated to match.

Measured on three Python trees (libresdr, ADRC-betaflight,
bot_predlogka_big_project — 799 files): 0 lost refs, 0 re-attributed, +3057
new; node and edge counts unchanged. Kernel parity holds — 6221/6221 files
byte-identical across the sweep.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

CodeGraph does not index call edges from anonymous/lambda functions

1 participant