Skip to content

Multiple named imports from one specifier produce only a single IMPORTS edge — sibling symbols are invisible to the graph #768

Description

@apappas1129

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

  1. 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();
}
  1. 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)

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

  • I searched existing issues and this is not a duplicate. (Related but distinct: ES/TS module specifiers produce zero IMPORTS edges — pipeline resolves by name only #180, closed, covers zero IMPORTS edges from unresolved package-name specifiers entirely — a different failure mode from this one, where resolution succeeds but only one of several names from the same specifier is kept.)
  • My reproduction uses shareable code (a dummy snippet), not proprietary code.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions