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 wildcard tsconfig.json path alias ("@lib/*": ["./src/lib/*"]) matches any import specifier that shares its string prefix — including an unrelated scoped npm package (@lib/external-pkg, resolved normally from node_modules) that happens to start with the same @lib/ prefix. When the substituted candidate doesn't correspond to a real, already-indexed file, resolution doesn't just fail cleanly: a later fallback strategy re-tries a shortened version of the import path, which matches the tsconfig's other, bare (non-wildcard) alias entry for the same prefix — and that one points at a real directory. The result is an IMPORTS edge from the importing file straight to that directory's Folder node, not to any file that was ever indexed.
Expected: the alias candidate should only "win" if it resolves to a real, indexed file — exactly how tsc itself handles paths: it tries the mapped candidate first, and if that path doesn't resolve to an actual file, it falls through to normal node_modules resolution. Right now this engine has no such fallback/verification step, so any scoped npm package sharing a prefix with a local alias is silently misresolved.
Reproduction
- Minimal dummy repo:
tsconfig.json:
{
"compilerOptions": {
"paths": {
"@lib": ["./src/lib"],
"@lib/*": ["./src/lib/*"]
}
}
}
src/lib/thing.ts:
src/consumer.ts:
import { ClientC } from '@lib/external-pkg';
export function useClient() {
return new ClientC();
}
Note there is no src/lib/external-pkg.ts — @lib/external-pkg is meant to be a real external package (e.g. published as @lib/external-pkg on npm), not a local module. The bug reproduces whether or not it's actually present in node_modules, since the resolver never gets that far.
- Commands:
codebase-memory-mcp cli index_repository '{"repo_path":"/tmp/lib-demo"}'
codebase-memory-mcp cli query_graph '{"project":"lib-demo","query":"MATCH (f)-[r:IMPORTS]->(t) WHERE f.file_path CONTAINS \"consumer.ts\" RETURN f.file_path, t.file_path, t.name, t.label, r.local_name"}'
(adjust project to whatever name the indexer assigns your repo)
- Result vs expected:
- Actual (reproduced and confirmed against the built binary): one
IMPORTS row — t.file_path = "src/lib", t.name = "lib", t.label = "Folder", r.local_name = "ClientC" — a phantom edge to the directory's Folder node, which was never a real import target.
- Expected: either no edge (external/unresolved, same as any other bare npm package import) or an edge modeled as external — never an edge into a
Folder node, or into any node the aliased path doesn't genuinely correspond to.
Real-world instance this was distilled from: a production monorepo with "@vendor": ["./src/app/vendor"] + "@vendor/*": ["./src/app/vendor/*"] aliases for an internal integration folder, alongside a real npm package @vendor/sdk (and its @vendor/sdk/module subpath). Every import from the real npm package got misattributed as an edge into the local src/app/vendor folder, and collapsed onto/overwrote genuine local edges pointing at the same folder (see companion issue: multiple names per specifier only keep one edge).
Root cause (verified by reading the resolver and reproducing against the built binary)
This is not primarily a bug in cbm_path_alias_resolve() (src/pipeline/path_alias.c) itself. Its wildcard match on the full import string (@lib/external-pkg → src/lib/external-pkg) already fails cleanly today: cbm_pipeline_resolve_import_node's Strategy 1 (src/pipeline/pass_pkgmap.c) looks that resolved QN up via cbm_gbuf_find_by_qn(), finds nothing (the file doesn't exist), and correctly falls through.
The actual phantom edge comes from Strategy 4 ("crate-relative module path", cbm_pipeline_resolve_import_node, ~line 1522) — a generic fallback applied to every language, not just Rust, that progressively strips trailing path segments off the unresolved import string and re-resolves each shorter candidate:
for (;;) {
char *rqn = cbm_pipeline_resolve_module(ctx, source_rel, work);
const cbm_gbuf_node_t *n = rqn ? cbm_gbuf_find_by_qn(ctx->gbuf, rqn) : NULL;
free(rqn);
if (n && (!source_file_qn || !n->qualified_name ||
strcmp(n->qualified_name, source_file_qn) != 0)) {
return n;
}
char *sl = strrchr(work, '/');
if (!sl) break;
*sl = '\0';
}
Truncating @lib/external-pkg down to @lib hits the tsconfig's other, bare (non-wildcard) "@lib": ["./src/lib"] entry. cbm_path_alias_resolve() accepts that match unconditionally too (same "no existence check" as the wildcard case) and returns src/lib. The resulting QN happens to be identical to the real Folder node's QN for the src/lib directory (cbm_pipeline_fqn_module() and cbm_pipeline_fqn_folder() both dot-join path segments with no type tag, so a module path with no file extension collides with the folder at the same path). cbm_gbuf_find_by_qn() genuinely finds a node there — just the wrong kind — and Strategy 4 accepts it.
The missing check is a label filter, not existence per se: cbm_pipeline_resolve_import_node's Strategy 3 (symbol-name fallback, ~line 1436) already guards its matches with import_targetable_label(), which allow-lists Class/Interface/Function/Method/Module/Struct/Enum/Trait/Type/File and excludes Folder/Project/Branch/etc. Strategy 1 (top-level, ~line 1352), Strategy 1b (resolve_sibling_file(), ~line 1329), and Strategy 4 (~line 1574) all call cbm_gbuf_find_by_qn() directly and accept whatever comes back, without that same filter. A bare (non-wildcard) alias pointing straight at a directory can hit this via Strategy 1 alone, with no truncation/retry needed at all — Strategy 4 is just the path this specific reproduction happens to take.
Suggested fix direction
Apply the same import_targetable_label() guard Strategy 3 already uses to the cbm_gbuf_find_by_qn() matches in Strategy 1, resolve_sibling_file() (Strategy 1b), and Strategy 4 — all in src/pipeline/pass_pkgmap.c. This is a single, already-established pattern applied at every place cbm_pipeline_resolve_import_node accepts a graph-node match, so it closes the gap regardless of which strategy (or future one) produces a same-QN-as-a-folder collision. No changes to path_alias.c are needed: its wildcard/exact matching mirrors tsc's own "try the alias candidate first" behavior, and it has no access to the graph to verify existence anyway — the verification belongs where the resolved QN gets checked against real nodes, which is pass_pkgmap.c.
(Strategy 2, the namespace map, doesn't need the guard: its values are built exclusively from __file__-suffixed QNs by cbm_pipeline_namespace_map_build(), a closed set the pipeline itself constructs — there's no path for it to ever return a Folder node.)
Note this is adjacent to but distinct from #730 / #766: that fix normalizes ../ climbs in a tsconfig's declared target value at config-load time (resolve_target_relative()). This report is about a downstream resolution-strategy gap in pass_pkgmap.c — cbm_path_alias_resolve() itself is untouched by #766 and doesn't need further changes here either.
Logs
(none — pure resolution logic issue, not a crash/perf issue)
Diagnostics trajectory (memory / performance / leak issues)
N/A
Project scale (if relevant)
Reproduces on a 4-file dummy repo; also observed on a ~3,300-node / ~7,600-edge production repo.
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 wildcard
tsconfig.jsonpath alias ("@lib/*": ["./src/lib/*"]) matches any import specifier that shares its string prefix — including an unrelated scoped npm package (@lib/external-pkg, resolved normally fromnode_modules) that happens to start with the same@lib/prefix. When the substituted candidate doesn't correspond to a real, already-indexed file, resolution doesn't just fail cleanly: a later fallback strategy re-tries a shortened version of the import path, which matches the tsconfig's other, bare (non-wildcard) alias entry for the same prefix — and that one points at a real directory. The result is anIMPORTSedge from the importing file straight to that directory's Folder node, not to any file that was ever indexed.Expected: the alias candidate should only "win" if it resolves to a real, indexed file — exactly how
tscitself handlespaths: it tries the mapped candidate first, and if that path doesn't resolve to an actual file, it falls through to normalnode_modulesresolution. Right now this engine has no such fallback/verification step, so any scoped npm package sharing a prefix with a local alias is silently misresolved.Reproduction
tsconfig.json:{ "compilerOptions": { "paths": { "@lib": ["./src/lib"], "@lib/*": ["./src/lib/*"] } } }src/lib/thing.ts:src/consumer.ts:Note there is no
src/lib/external-pkg.ts—@lib/external-pkgis meant to be a real external package (e.g. published as@lib/external-pkgon npm), not a local module. The bug reproduces whether or not it's actually present innode_modules, since the resolver never gets that far.(adjust
projectto whatever name the indexer assigns your repo)IMPORTSrow —t.file_path = "src/lib",t.name = "lib",t.label = "Folder",r.local_name = "ClientC"— a phantom edge to the directory's Folder node, which was never a real import target.Foldernode, or into any node the aliased path doesn't genuinely correspond to.Real-world instance this was distilled from: a production monorepo with
"@vendor": ["./src/app/vendor"]+"@vendor/*": ["./src/app/vendor/*"]aliases for an internal integration folder, alongside a real npm package@vendor/sdk(and its@vendor/sdk/modulesubpath). Every import from the real npm package got misattributed as an edge into the localsrc/app/vendorfolder, and collapsed onto/overwrote genuine local edges pointing at the same folder (see companion issue: multiple names per specifier only keep one edge).Root cause (verified by reading the resolver and reproducing against the built binary)
This is not primarily a bug in
cbm_path_alias_resolve()(src/pipeline/path_alias.c) itself. Its wildcard match on the full import string (@lib/external-pkg→src/lib/external-pkg) already fails cleanly today:cbm_pipeline_resolve_import_node's Strategy 1 (src/pipeline/pass_pkgmap.c) looks that resolved QN up viacbm_gbuf_find_by_qn(), finds nothing (the file doesn't exist), and correctly falls through.The actual phantom edge comes from Strategy 4 ("crate-relative module path",
cbm_pipeline_resolve_import_node, ~line 1522) — a generic fallback applied to every language, not just Rust, that progressively strips trailing path segments off the unresolved import string and re-resolves each shorter candidate:Truncating
@lib/external-pkgdown to@libhits the tsconfig's other, bare (non-wildcard)"@lib": ["./src/lib"]entry.cbm_path_alias_resolve()accepts that match unconditionally too (same "no existence check" as the wildcard case) and returnssrc/lib. The resulting QN happens to be identical to the real Folder node's QN for thesrc/libdirectory (cbm_pipeline_fqn_module()andcbm_pipeline_fqn_folder()both dot-join path segments with no type tag, so a module path with no file extension collides with the folder at the same path).cbm_gbuf_find_by_qn()genuinely finds a node there — just the wrong kind — and Strategy 4 accepts it.The missing check is a label filter, not existence per se:
cbm_pipeline_resolve_import_node's Strategy 3 (symbol-name fallback, ~line 1436) already guards its matches withimport_targetable_label(), which allow-listsClass/Interface/Function/Method/Module/Struct/Enum/Trait/Type/Fileand excludesFolder/Project/Branch/etc. Strategy 1 (top-level, ~line 1352), Strategy 1b (resolve_sibling_file(), ~line 1329), and Strategy 4 (~line 1574) all callcbm_gbuf_find_by_qn()directly and accept whatever comes back, without that same filter. A bare (non-wildcard) alias pointing straight at a directory can hit this via Strategy 1 alone, with no truncation/retry needed at all — Strategy 4 is just the path this specific reproduction happens to take.Suggested fix direction
Apply the same
import_targetable_label()guard Strategy 3 already uses to thecbm_gbuf_find_by_qn()matches in Strategy 1,resolve_sibling_file()(Strategy 1b), and Strategy 4 — all insrc/pipeline/pass_pkgmap.c. This is a single, already-established pattern applied at every placecbm_pipeline_resolve_import_nodeaccepts a graph-node match, so it closes the gap regardless of which strategy (or future one) produces a same-QN-as-a-folder collision. No changes topath_alias.care needed: its wildcard/exact matching mirrorstsc's own "try the alias candidate first" behavior, and it has no access to the graph to verify existence anyway — the verification belongs where the resolved QN gets checked against real nodes, which ispass_pkgmap.c.(Strategy 2, the namespace map, doesn't need the guard: its values are built exclusively from
__file__-suffixed QNs bycbm_pipeline_namespace_map_build(), a closed set the pipeline itself constructs — there's no path for it to ever return a Folder node.)Note this is adjacent to but distinct from #730 / #766: that fix normalizes
../climbs in a tsconfig's declared target value at config-load time (resolve_target_relative()). This report is about a downstream resolution-strategy gap inpass_pkgmap.c—cbm_path_alias_resolve()itself is untouched by #766 and doesn't need further changes here either.Logs
(none — pure resolution logic issue, not a crash/perf issue)
Diagnostics trajectory (memory / performance / leak issues)
N/A
Project scale (if relevant)
Reproduces on a 4-file dummy repo; also observed on a ~3,300-node / ~7,600-edge production repo.
Confirmations