Skip to content

trace_path reports callers_total: 0 with relation: eq for injected-receiver calls; the resolver already records the unresolved site but drops it before any output #2265

Description

@BryanQuiceno

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:

  1. 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.
  2. 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.
  3. 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.jsstatus: 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:846pass.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-8188cbm_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-324index_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)

  1. 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.
  2. 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.
  3. 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

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 edgesux/behaviorDisplay bugs, docs, adoption UXwindowsWindows-specific issues

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions