Skip to content

Wildcard tsconfig path alias resolves a same-prefix scoped npm package as a local folder (no import-target label check) #767

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

  1. Minimal dummy repo:

tsconfig.json:

{
  "compilerOptions": {
    "paths": {
      "@lib": ["./src/lib"],
      "@lib/*": ["./src/lib/*"]
    }
  }
}

src/lib/thing.ts:

export const Thing = {};

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.

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

  1. 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-pkgsrc/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.ccbm_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

  • I searched existing issues and this is not a duplicate.
  • 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