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

    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