Version
codebase-memory-mcp 0.11.0
Platform
Windows (x64)
Install channel
GitHub release archive / install.ps1
Binary variant
ui
What happened, and what did you expect?
When a function is invoked as a method on an injected receiver — a destructured factory parameter, this.dep inside a class, or an object of functions passed as an argument — trace_path returns callers_total: 0 with callers_total_relation: eq, and the outbound direction returns callees_total: 0, also eq. Each of these calls has a real caller in the same repository.
I am not asking for this call shape to be resolved — that is already tracked in #514 and #1354. I am reporting something narrower and, I believe, cheaper:
- The TS/JS resolver already produces a record for each of these sites, with a reason and exact source span. That record never reaches the graph, the coverage report, or any visible log. It is dropped in memory.
- Meanwhile the public output states the zero is exact.
callers_total_relation only flips to gte on pagination (mcp.c:9394, tr_in.truncated ? "gte" : "eq"), never on incomplete resolution.
check_index_coverage with diagnostics: "full" answers no_recorded_issue for the file containing the unresolved site — the tool meant to expose blind spots does not expose this one.
Expected: a zero produced by failed resolution should be distinguishable from a true zero.
This is the part that #1682 set aside: @pcristin proposed "narrowing … to persisting and exposing unresolved callsite diagnostics" and @DeusData agreed it "can remain a separate fo[llow-up]". I could not find that follow-up, so this is it.
Reproduction
Nine plain JavaScript (ESM) files, no TypeScript, no framework. 35 nodes / 43 edges.
// cliente.js — function inside a factory-returned object
export function crearCliente() {
function buscar(id) { return { id }; }
return { buscar };
}
// buscador.js — control: top-level function
export function buscarPlano(id) { return { id, plano: true }; }
// servicio.js — receiver: destructured parameter
export function crearServicio({ cliente }) {
async function procesar(id) { return cliente.buscar(id); }
return { procesar };
}
// servicio-plano.js — injected receiver, top-level target
export function crearServicioPlano({ buscador }) {
function procesarPlano(id) { return buscador.buscarPlano(id); }
return { procesarPlano };
}
// clase.js — receiver: this.dep
export class ServicioClase {
constructor(cliente) { this.cliente = cliente; }
procesarConClase(id) { return this.cliente.buscar(id); }
}
// servicio-tipado.js — same as servicio.js, parameter typed via JSDoc
/** @param {{ cliente: ReturnType<typeof import("./cliente.js").crearCliente> }} deps */
export function crearServicioTipado({ cliente }) {
function procesarTipado(id) { return cliente.buscar(id); }
return { procesarTipado };
}
// ayudante.js + directo.js — control: import + direct call
export function ayudante(x) { return x + 1; }
import { ayudante } from "./ayudante.js";
export function usarDirecto() { return ayudante(1); }
// arranque.js — composition
import { crearCliente } from "./cliente.js";
import { crearServicio } from "./servicio.js";
const servicio = crearServicio({ cliente: crearCliente() });
servicio.procesar(42);
codebase-memory-mcp cli index_repository --repo-path <path> --mode full
codebase-memory-mcp cli trace_path --project <p> --function_name <f> --direction inbound --include_evidence true
codebase-memory-mcp cli trace_path --project <p> --function_name <f> --direction outbound --include_evidence true
codebase-memory-mcp cli check_index_coverage --project <p> --paths servicio.js --diagnostics full
Result
| target |
call shape |
inbound |
caller's outbound |
actual |
ayudante |
import + direct call |
✅ 1 caller, heuristic 0.95 |
✅ 1 callee |
1 |
buscar |
cliente.buscar() — destructured param |
❌ 0, eq |
❌ procesar: 0, eq |
1 |
buscar |
this.cliente.buscar() — class |
❌ 0, eq |
❌ procesarConClase: 0, eq |
+1 |
buscarPlano |
buscador.buscarPlano() — top-level target |
❌ 0, eq |
❌ procesarPlano: 0, eq |
1 |
buscar |
same, JSDoc-typed param |
❌ 0, eq |
❌ procesarTipado: 0, eq |
+1 |
query_graph with MATCH (a)-[e]->(b:Function) WHERE b.name IN ['buscar','buscarPlano'] RETURN a.name, type(e), b.name returns only DEFINES edges — no usage edge of any confidence.
check_index_coverage for servicio.js, servicio-plano.js, clase.js → status: no_recorded_issue, recommended_action: use_graph_with_best_effort_caveat.
All four injected variants fail identically while the direct call succeeds, which isolates the cause to the injected receiver — not the nested definition, not the absence of types.
Where the data is lost (read on main @ def38f3)
internal/cbm/lsp/ts_lsp.c:2825-2858 — for cliente.buscar(id), cliente has a scope binding (:2494-2502 binds destructured parameters), so the namespace-import branch is skipped and the call goes to type-based dispatch; ts_eval_expr_type yields no type, ts_lookup_method_for_call fails, and ts_emit_unresolved_call_at(ctx, mname, "method_not_in_registry", call_node) runs (:2858). That creates a CBMResolvedCall with strategy = "lsp_unresolved", confidence = 0.0, a reason, and site_start_byte/site_end_byte (:285-300). The site, the method name and the reason all exist at this point.
src/pipeline/pass_parallel.c:2716 and :3426 — any record with confidence < CBM_LSP_CONFIDENCE_FLOOR (0.6f, lsp_resolve.h:39) is never an edge candidate. Correct as graph policy — but the record is not stored anywhere else either.
src/pipeline/pass_calls.c:846 — pass.done … "unresolved" N is emitted via cbm_log_info, but it appears neither in the per-project log (which only carries the coverage report) nor in cbm-daemon.log; verified on both after indexing.
src/mcp/mcp.c:8184-8188 — cbm_mcp_edge_strategy_class already defines the public class "unresolved". The output vocabulary has the word; nothing ever reaches it because an lsp_unresolved never becomes an edge.
src/store/store.c:303-324 — index_coverage stores parse_partial / parse_unusable / skip phases, and its own comment says "name a new kind so that distinction stays obvious". That is exactly where this would fit without touching the graph tables.
Proposal (cheapest first; any one of them removes the false zero)
- Stop reporting
eq on callers_total_relation / callees_total_relation when unresolved sites could account for the answer. For outbound, it is enough that the caller has lsp_unresolved records (already available per function). For inbound, that any unresolved record's leaf name matches the function name. Result: gte — or a new unknown value if gte is to stay reserved for pagination.
- Persist unresolved sites in
index_coverage as a new kind, e.g. unresolved_calls, with detail = "line:leaf_name:reason,…". It matches the table's declared design (metadata about the graph, kept separate from it) and makes check_index_coverage stop answering no_recorded_issue for the affected file, with a recommended action such as read_source_and_verify_calls.
- Have
trace_path expose a count and rows of unresolved sites (unresolved_sites_total, with file, line, leaf name and reason), using the "unresolved" class that already exists in cbm_mcp_edge_strategy_class. The reason values already emitted by TS/JS are method_not_in_registry, func_not_in_registry, import_symbol_not_in_registry, module_symbol_not_in_registry.
Why it matters
We run cbm with autonomous agents on a Node server built with factory injection (crearX({ dep })) — exactly this shape. "This function has no callers" is the answer that leads an agent to conclude code is dead. We currently mitigate with an operating rule ("confirm any zero with grep"), but that depends on the agent remembering. If the output said "0 resolved, N unresolved", the rule would be unnecessary.
If the maintainers agree on a shape for option 2, I would like to attempt a PR: the data is already in CBMResolvedCall, and both the table and the coverage report already exist.
Logs
Per-project index log after index_repository (MCP mode) contains only the coverage report — no pass.done line and no unresolved count:
# codebase-memory-mcp index coverage report
# project=<p> skipped=0 parse_partial=0
# columns: phase reason path
cbm-daemon.log for the same run: no pass.done pass=calls line either.
Project scale (if relevant)
35 nodes / 43 edges / 9 files (reproduction). Also observed on a 13,094-node / 31,523-edge Node repository.
Confirmations
Version
codebase-memory-mcp 0.11.0Platform
Windows (x64)
Install channel
GitHub release archive / install.ps1
Binary variant
ui
What happened, and what did you expect?
When a function is invoked as a method on an injected receiver — a destructured factory parameter,
this.depinside a class, or an object of functions passed as an argument —trace_pathreturnscallers_total: 0withcallers_total_relation: eq, and the outbound direction returnscallees_total: 0, alsoeq. Each of these calls has a real caller in the same repository.I am not asking for this call shape to be resolved — that is already tracked in #514 and #1354. I am reporting something narrower and, I believe, cheaper:
callers_total_relationonly flips togteon pagination (mcp.c:9394,tr_in.truncated ? "gte" : "eq"), never on incomplete resolution.check_index_coveragewithdiagnostics: "full"answersno_recorded_issuefor the file containing the unresolved site — the tool meant to expose blind spots does not expose this one.Expected: a zero produced by failed resolution should be distinguishable from a true zero.
This is the part that #1682 set aside: @pcristin proposed "narrowing … to persisting and exposing unresolved callsite diagnostics" and @DeusData agreed it "can remain a separate fo[llow-up]". I could not find that follow-up, so this is it.
Reproduction
Nine plain JavaScript (ESM) files, no TypeScript, no framework. 35 nodes / 43 edges.
Result
inboundoutboundayudanteheuristic 0.95buscarcliente.buscar()— destructured parameqprocesar: 0,eqbuscarthis.cliente.buscar()— classeqprocesarConClase: 0,eqbuscarPlanobuscador.buscarPlano()— top-level targeteqprocesarPlano: 0,eqbuscareqprocesarTipado: 0,eqquery_graphwithMATCH (a)-[e]->(b:Function) WHERE b.name IN ['buscar','buscarPlano'] RETURN a.name, type(e), b.namereturns onlyDEFINESedges — no usage edge of any confidence.check_index_coverageforservicio.js,servicio-plano.js,clase.js→status: no_recorded_issue,recommended_action: use_graph_with_best_effort_caveat.All four injected variants fail identically while the direct call succeeds, which isolates the cause to the injected receiver — not the nested definition, not the absence of types.
Where the data is lost (read on
main@def38f3)internal/cbm/lsp/ts_lsp.c:2825-2858— forcliente.buscar(id),clientehas a scope binding (:2494-2502binds destructured parameters), so the namespace-import branch is skipped and the call goes to type-based dispatch;ts_eval_expr_typeyields no type,ts_lookup_method_for_callfails, andts_emit_unresolved_call_at(ctx, mname, "method_not_in_registry", call_node)runs (:2858). That creates aCBMResolvedCallwithstrategy = "lsp_unresolved",confidence = 0.0, areason, andsite_start_byte/site_end_byte(:285-300). The site, the method name and the reason all exist at this point.src/pipeline/pass_parallel.c:2716and:3426— any record withconfidence < CBM_LSP_CONFIDENCE_FLOOR(0.6f,lsp_resolve.h:39) is never an edge candidate. Correct as graph policy — but the record is not stored anywhere else either.src/pipeline/pass_calls.c:846—pass.done … "unresolved" Nis emitted viacbm_log_info, but it appears neither in the per-project log (which only carries the coverage report) nor incbm-daemon.log; verified on both after indexing.src/mcp/mcp.c:8184-8188—cbm_mcp_edge_strategy_classalready defines the public class"unresolved". The output vocabulary has the word; nothing ever reaches it because anlsp_unresolvednever becomes an edge.src/store/store.c:303-324—index_coveragestoresparse_partial/parse_unusable/ skip phases, and its own comment says "name a new kind so that distinction stays obvious". That is exactly where this would fit without touching the graph tables.Proposal (cheapest first; any one of them removes the false zero)
eqoncallers_total_relation/callees_total_relationwhen unresolved sites could account for the answer. Foroutbound, it is enough that the caller haslsp_unresolvedrecords (already available per function). Forinbound, that any unresolved record's leaf name matches the function name. Result:gte— or a newunknownvalue ifgteis to stay reserved for pagination.index_coverageas a newkind, e.g.unresolved_calls, withdetail = "line:leaf_name:reason,…". It matches the table's declared design (metadata about the graph, kept separate from it) and makescheck_index_coveragestop answeringno_recorded_issuefor the affected file, with a recommended action such asread_source_and_verify_calls.trace_pathexpose a count and rows of unresolved sites (unresolved_sites_total, with file, line, leaf name andreason), using the"unresolved"class that already exists incbm_mcp_edge_strategy_class. Thereasonvalues already emitted by TS/JS aremethod_not_in_registry,func_not_in_registry,import_symbol_not_in_registry,module_symbol_not_in_registry.Why it matters
We run cbm with autonomous agents on a Node server built with factory injection (
crearX({ dep })) — exactly this shape. "This function has no callers" is the answer that leads an agent to conclude code is dead. We currently mitigate with an operating rule ("confirm any zero with grep"), but that depends on the agent remembering. If the output said "0 resolved, N unresolved", the rule would be unnecessary.If the maintainers agree on a shape for option 2, I would like to attempt a PR: the data is already in
CBMResolvedCall, and both the table and the coverage report already exist.Logs
Per-project index log after
index_repository(MCP mode) contains only the coverage report — nopass.doneline and no unresolved count:cbm-daemon.logfor the same run: nopass.done pass=callsline either.Project scale (if relevant)
35 nodes / 43 edges / 9 files (reproduction). Also observed on a 13,094-node / 31,523-edge Node repository.
Confirmations