Skip to content

Calls inside a closure/lambda are attributed to the enclosing named function, producing false CALLS edges (C++, JS, Python, Rust) #2293

Description

@jjacke13

Version: codebase-memory-mcp dev (built from source at e783f73 on main)
Platform: Linux (x64)
Install channel: Built from source
Binary variant: standard
Project scale: 7,838 nodes / 28,238 edges

(Filed via gh rather than the issue form, so the dropdown fields are listed above.)

What happened, and what did you expect?

A call written inside a closure is recorded as a CALLS edge from the enclosing named function, rather than from the closure. Because a closure is usually stored as a callback and invoked later, the function that registers it is reported as a caller of everything in its body, while the function that actually invokes it is not reported at all.

In the minimal repro below, trace_path --direction inbound on target reports exactly one caller: registrar, which only assigns the closure. dispatcher, which actually invokes it, has 0 in / 0 out edges. So the answer is wrong in both directions at once — it lists a caller that does not call, and omits the caller that does.

An empty or partial answer would be safe; a plausible-looking wrong one is not, because "nothing else reaches this function" is exactly the conclusion a user draws before changing lifetime or teardown code.

Expected: a function that only registers a callback should not be reported as a caller of that callback's body. Whether the edge should be sourced at an anonymous-function node, carry an edge type other than CALLS, or be omitted is your design call — I note that the first option appears to need a new node label (README.md lists the label set and has no Lambda/Closure), so it is presumably the expensive one.

To be clear about what I am not reporting: resolving an indirect call such as slot() or obj->cb(args) back to the closure stored there is outside what static AST analysis can do. That missing edge is not a bug. This report is only about the false edge from the registration site.

Prior art in the repo that partly contradicts this. tests/repro/repro_invariant_enclosing_parity.c asserts that for a fixture where every call site is inside a function body, every CALLS edge must be sourced at a Function/Method node and never at Module — and its Nix case deliberately wants a lambda-body call sourced to the bound function. That rationale rests on the lambda having a binding name. An anonymous closure has none, so upholding the invariant there costs a false edge from a function that merely registers the callback. I may be misreading the intent, in which case this is a feature request rather than a bug.

Minor, separate: the binary reports version dev because CBM_VERSION is not defined in this build path (src/main.c:115-116 falls back to "dev"), while flake.nix:66 sets version = "0.11.0". Noting it so the Version field above doesn't look like a mistake.

Where I traced it (I may have the mechanism wrong)

  • internal/cbm/helpers.c:887 cbm_find_enclosing_func() picks the source node for a CALLS edge by walking a call node's ancestry for a parent whose type is in a per-language list from func_kinds_for_lang() (helpers.c:815).
  • For C++ that list is func_kinds_cpp[] = {"function_definition", NULL} (helpers.c:792) — no lambda_expression — so the walk passes through the lambda to the enclosing function.
  • But func_kinds_js[] (helpers.c:788) does list arrow_function and function_expression, and JS still attributes to the enclosing named function. So the node-type list alone does not decide it, and I have not traced where the anonymous node is dropped — possibly QN resolution for a nameless callable.

I am not proposing a patch, because the identity of an anonymous callable looks like the real design decision. Happy to open a PR if you tell me which semantics you want. Flagging per CONTRIBUTING.md ("New pipeline passes or indexing algorithms — anything that changes what gets extracted or how") that this seemed worth agreeing on before writing code.

Reproduction

One file per project, four separate projects. Each is a git repo containing only the file shown.

a.cpp

#include <functional>
void target() {}
void registrar(std::function<void()>& slot) { slot = []() { target(); }; }
void dispatcher(std::function<void()>& slot) { slot(); }

a.js

function target() {}
function registrar(slot) { slot.cb = () => { target(); }; }
function dispatcher(slot) { slot.cb(); }

a.py

def target(): pass
def registrar(slot): slot.cb = lambda: target()
def dispatcher(slot): slot.cb()

a.rs

fn target() {}
fn registrar() -> impl Fn() { || { target(); } }
fn dispatcher(f: impl Fn()) { f(); }

For each:

codebase-memory-mcp cli --quiet index_repository --repo-path /tmp/cbm-<lang>
codebase-memory-mcp cli --quiet trace_path --project cbm-<lang> \
    --function-name target --direction inbound --depth 3

Actual result — identical in all four languages:

--- cpp ---
callers_total: 1
  tmp-cbm-cpp.a.registrar 1
--- js ---
callers_total: 1
  tmp-cbm-js.a.registrar 1
--- py ---
callers_total: 1
  tmp-cbm-py.a.registrar 1
--- rs ---
callers_total: 1
  tmp-cbm-rs.a.registrar 1

search_graph --project cbm-cpp --label Function shows the closure produces no node of its own, and that dispatcher is unconnected:

  tmp-cbm-cpp.a.dispatcher Function a.cpp 4-4 0 0
  tmp-cbm-cpp.a.registrar  Function a.cpp 3-3 0 1
  tmp-cbm-cpp.a.target     Function a.cpp 2-2 1 0

The relevant code (func_kinds_cpp[], and the absence of closure handling in the attribution walk) is unchanged at tag v0.11.0, though I re-ran the binary only on main.

trace_call_path returns the same result. I did not find another tool or flag that gives the correct answer; ingest_traces is scoped to HTTP_CALLS, so it is not a route to in-process callbacks either.

Confirmations

  • I searched existing issues and this is not a duplicate.
  • My reproduction uses shareable code (a dummy snippet), not proprietary code.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    parsing/qualityGraph extraction bugs, false positives, missing edges

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions