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

    bugSomething isn't workingparsing/qualityGraph extraction bugs, false positives, missing edgespriority/normalStandard review queue; useful PR with ordinary maintainer urgency.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions