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
Version:
codebase-memory-mcp dev(built from source ate783f73onmain)Platform: Linux (x64)
Install channel: Built from source
Binary variant: standard
Project scale: 7,838 nodes / 28,238 edges
(Filed via
ghrather 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
CALLSedge 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 inboundontargetreports 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.mdlists 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()orobj->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.casserts that for a fixture where every call site is inside a function body, everyCALLSedge must be sourced at aFunction/Methodnode and never atModule— 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
devbecauseCBM_VERSIONis not defined in this build path (src/main.c:115-116falls back to"dev"), whileflake.nix:66setsversion = "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:887cbm_find_enclosing_func()picks the source node for aCALLSedge by walking a call node's ancestry for a parent whose type is in a per-language list fromfunc_kinds_for_lang()(helpers.c:815).func_kinds_cpp[] = {"function_definition", NULL}(helpers.c:792) — nolambda_expression— so the walk passes through the lambda to the enclosing function.func_kinds_js[](helpers.c:788) does listarrow_functionandfunction_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.cppa.jsa.pya.rsFor each:
Actual result — identical in all four languages:
search_graph --project cbm-cpp --label Functionshows the closure produces no node of its own, and thatdispatcheris unconnected:The relevant code (
func_kinds_cpp[], and the absence of closure handling in the attribution walk) is unchanged at tagv0.11.0, though I re-ran the binary only onmain.trace_call_pathreturns the same result. I did not find another tool or flag that gives the correct answer;ingest_tracesis scoped toHTTP_CALLS, so it is not a route to in-process callbacks either.Confirmations