Version
codebase-memory-mcp 0.8.1
Platform
(pick yours — mine was Linux (x64))
Install channel
GitHub release archive / install.sh / install.ps1
Binary variant
standard
What happened, and what did you expect?
A single import { A, B } from './lib' statement — two named symbols, one specifier, zero ambiguity, no aliasing involved at all — produces exactly one IMPORTS edge in the graph, carrying only one of the two local_name values. The other imported (and actually used) symbol has no edge at all: it's as if that half of the import statement was never parsed.
Expected: the graph should represent both A and B as being imported by the consumer — either as two separate IMPORTS edges (one per symbol) or as a single edge whose local_name/equivalent property is a list of all symbols imported from that specifier. Either way, no imported-and-used symbol should be silently dropped.
This causes real correctness problems downstream: "who imports X" / caller / dead-code / impact-analysis queries return zero results for whichever symbol got dropped, even though the symbol is genuinely imported and called in the file. Worse, it's not limited to those queries — see "Root cause" below: the same dropped edge also breaks cross-file call resolution for that symbol, not just the import edge itself.
Reproduction
- Minimal dummy repo (no tsconfig paths/aliases needed — plain relative import, to isolate this from any alias-resolution issue):
src/lib.ts:
export const A = {
method: () => 'value',
};
export const B = {
check: () => true,
};
src/consumer.ts:
import { A, B } from './lib';
export function run() {
return B.check() && A.method();
}
- Commands:
codebase-memory-mcp cli index_repository '{"repo_path":"/tmp/multi-import-demo"}'
codebase-memory-mcp cli query_graph '{"project":"multi-import-demo","query":"MATCH (f)-[r:IMPORTS]->(t) WHERE f.file_path CONTAINS \"consumer.ts\" RETURN t.file_path, r.local_name"}'
(adjust project to whatever name the indexer assigns your repo)
- Result vs expected:
- Actual (reproduced and confirmed against the built binary): exactly 1 row returned —
[["src/lib.ts","B"]]. A has zero representation as an IMPORTS edge, even though it's imported and called in the file.
- Expected: 2 rows (or 1 row with both names captured) — both symbols visible.
Real-world instance this was distilled from: a production TypeScript monorepo, import { A, B } from '@lib' in a consumer file, resolving cleanly to src/app/lib (no alias ambiguity — @lib is a bare, non-wildcard tsconfig entry). Graph kept the B edge; A had zero representation, even though search_graph/grep both confirm it's imported and called in that file.
Root cause (verified by reading the edge-writer and reproducing against the built binary)
cbm_gbuf_insert_edge() in src/graph_buffer/graph_buffer.c dedups every edge purely on (source_id, target_id, type) — see make_edge_key() (~line 138) and its use inside cbm_gbuf_insert_edge() (~line 914). Two named imports from the same specifier resolve to the same (source_file, target_file) pair, so the second IMPORTS edge collides on that key with the first. On collision the function does not merge or reject — it replaces the existing edge's properties_json outright:
cbm_gbuf_edge_t *existing = cbm_ht_get(gb->edge_by_key, key);
if (existing) {
/* Merge properties (just replace for now) */
if (properties_json && strcmp(properties_json, "{}") != 0) {
free(existing->properties_json);
existing->properties_json = heap_strdup(properties_json);
}
return existing->id;
}
The (just replace for now) comment suggests this was a known stub, never finished.
This has a bigger blast radius than just "who imports X" queries: src/pipeline/pass_calls.c, pass_usages.c, pass_semantic.c, and pass_lsp_cross.c all build their local_name → resolved module QN maps for cross-file call resolution by walking each file's IMPORTS edges and parsing exactly one local_name out of each edge's properties_json (strstr(props, "\"local_name\":\""), single value, no list handling). So the dropped symbol's calls also fail to resolve cross-file — this bug silently degrades call-graph accuracy, not just import-edge visibility.
Suggested fix direction
Key IMPORTS edges specifically on (source_id, target_id, type, local_name) instead of (source_id, target_id, type), so two different symbols imported from the same specifier become two distinct edges. This requires no changes to any of the four downstream consumers listed above — they already iterate edges and read one local_name per edge; they will simply see both edges once dedup stops collapsing them. Other edge types (CALLS, HTTP_CALLS, DEFINES, ...) should keep their current (source, target, type) key unchanged, since collapsing multiple call sites of the same callee into one edge there is presumably intentional.
Logs
(none — pure graph-construction/dedup issue, not a crash/perf issue)
Diagnostics trajectory (memory / performance / leak issues)
N/A
Project scale (if relevant)
Reproduces on a 2-file dummy repo; also observed on a ~3,300-node / ~7,600-edge production repo (multiple files affected, not isolated to one).
Confirmations
Version
codebase-memory-mcp 0.8.1
Platform
(pick yours — mine was Linux (x64))
Install channel
GitHub release archive / install.sh / install.ps1
Binary variant
standard
What happened, and what did you expect?
A single
import { A, B } from './lib'statement — two named symbols, one specifier, zero ambiguity, no aliasing involved at all — produces exactly oneIMPORTSedge in the graph, carrying only one of the twolocal_namevalues. The other imported (and actually used) symbol has no edge at all: it's as if that half of the import statement was never parsed.Expected: the graph should represent both
AandBas being imported by the consumer — either as two separateIMPORTSedges (one per symbol) or as a single edge whoselocal_name/equivalent property is a list of all symbols imported from that specifier. Either way, no imported-and-used symbol should be silently dropped.This causes real correctness problems downstream: "who imports X" / caller / dead-code / impact-analysis queries return zero results for whichever symbol got dropped, even though the symbol is genuinely imported and called in the file. Worse, it's not limited to those queries — see "Root cause" below: the same dropped edge also breaks cross-file call resolution for that symbol, not just the import edge itself.
Reproduction
src/lib.ts:src/consumer.ts:(adjust
projectto whatever name the indexer assigns your repo)[["src/lib.ts","B"]].Ahas zero representation as anIMPORTSedge, even though it's imported and called in the file.Real-world instance this was distilled from: a production TypeScript monorepo,
import { A, B } from '@lib'in a consumer file, resolving cleanly tosrc/app/lib(no alias ambiguity —@libis a bare, non-wildcard tsconfig entry). Graph kept theBedge;Ahad zero representation, even thoughsearch_graph/grep both confirm it's imported and called in that file.Root cause (verified by reading the edge-writer and reproducing against the built binary)
cbm_gbuf_insert_edge()insrc/graph_buffer/graph_buffer.cdedups every edge purely on(source_id, target_id, type)— seemake_edge_key()(~line 138) and its use insidecbm_gbuf_insert_edge()(~line 914). Two named imports from the same specifier resolve to the same(source_file, target_file)pair, so the secondIMPORTSedge collides on that key with the first. On collision the function does not merge or reject — it replaces the existing edge'sproperties_jsonoutright:The
(just replace for now)comment suggests this was a known stub, never finished.This has a bigger blast radius than just "who imports X" queries:
src/pipeline/pass_calls.c,pass_usages.c,pass_semantic.c, andpass_lsp_cross.call build theirlocal_name → resolved module QNmaps for cross-file call resolution by walking each file'sIMPORTSedges and parsing exactly onelocal_nameout of each edge'sproperties_json(strstr(props, "\"local_name\":\""), single value, no list handling). So the dropped symbol's calls also fail to resolve cross-file — this bug silently degrades call-graph accuracy, not just import-edge visibility.Suggested fix direction
Key
IMPORTSedges specifically on(source_id, target_id, type, local_name)instead of(source_id, target_id, type), so two different symbols imported from the same specifier become two distinct edges. This requires no changes to any of the four downstream consumers listed above — they already iterate edges and read onelocal_nameper edge; they will simply see both edges once dedup stops collapsing them. Other edge types (CALLS,HTTP_CALLS,DEFINES, ...) should keep their current(source, target, type)key unchanged, since collapsing multiple call sites of the same callee into one edge there is presumably intentional.Logs
(none — pure graph-construction/dedup issue, not a crash/perf issue)
Diagnostics trajectory (memory / performance / leak issues)
N/A
Project scale (if relevant)
Reproduces on a 2-file dummy repo; also observed on a ~3,300-node / ~7,600-edge production repo (multiple files affected, not isolated to one).
Confirmations