Skip to content

pnpm workspace: no IMPORTS edge when package.json entry points at gitignored dist/ — cross-package CALLS degrade to unique_name #1732

Description

@bearluo

Version

codebase-memory-mcp 0.10.8

Platform

Windows 11 Pro 26H2 (10.0.26200), x64

Install channel

GitHub release archive

Binary variant

standard

What happened, and what did you expect?

#180 (ES/TS module specifiers produce zero IMPORTS edges) was closed by #184, which builds a package map from package.json. That map keys on main / exports / types. In a source-first monorepo those fields point at build output (./dist/index.js) which is gitignored and therefore — correctly — not indexed. The specifier resolves to a path that has no node in the graph, the IMPORTS edge is silently dropped, and every cross-package call falls through to the unique_name fallback.

So #180's mapping step now succeeds syntactically but lands on nothing. The end state for the user is the same one #180 described.

Observed on a private TS pnpm monorepo (4 workspace packages + 2 apps, 321 files, 4,548 nodes / 9,822 edges, --mode full):

  • apps/demo/assets/foundation/net/auth.ts has 14 imported bindings — 13 from @cck/core, 1 from '../catalog'. The graph holds exactly one IMPORTS edge for that file, the relative one:

    MATCH (f:File)-[imp:IMPORTS]->(m) WHERE f.file_path = 'apps/demo/assets/foundation/net/auth.ts'
    RETURN imp.local_name, m.qualified_name
    
      LOGIN_UI   <project>.apps.demo.assets.foundation.catalog
    total: 1
    
  • packages/core/package.json declares "main": "./dist/index.js" and "exports": { ".": { "import": "./dist/index.js" } }. packages/core/dist/index.js exists on disk but .gitignore has dist/, so the indexer skips it: MATCH (f:File) WHERE f.file_path CONTAINS 'dist/' returns 0 rows. Meanwhile packages/core/src/index.ts is in the graph as <project>.packages.core.src.index.

  • The resulting CALLS strategy mix — unique_name is doing the work import_map should be doing:

    strategy confidence edges
    same_module 0.90 434
    lsp_ts_local / _import / _method / _namespace 0.95 219
    import_map 0.95 141
    qualified_suffix 0.90 76
    unique_name 0.75 / 0.38 271
    suffix_match 0.55 … 0.08 48

    319 of 1,190 CALLS edges (27%) are resolved by name alone; 162 of those cross a package boundary. All 141 import_map edges come from relative imports; not one comes from a @cck/* specifier.

Because unique_name also binds unresolvable names to unrelated same-named symbols (#1355), the two compound: the missing IMPORTS edges push cross-package calls into the guessing path, and the guessing path then invents layering violations. In our repo that surfaced as packages/engine → apps/demo and packages/core → apps/demo edges in the Graph UI that do not exist in the source at all.

Expected: when a package specifier's declared entry point resolves to a path with no node in the graph, fall back to a source entry before giving up — e.g. exports["."].development, publishConfig.exports, src/index.{ts,tsx,js}, or the source path implied by the files array (which in our case already lists src).

Reproduction

Self-contained dummy workspace. lib-dist points its entry at gitignored build output (the bug); lib-src points its entry at source (the control).

mkdir -p repro/packages/lib-dist/src repro/packages/lib-dist/dist \
         repro/packages/lib-src/src repro/apps/app/src && cd repro

printf 'packages:\n  - "packages/*"\n  - "apps/*"\n' > pnpm-workspace.yaml
printf 'dist/\nnode_modules/\n'                      > .gitignore
printf '{ "name": "repro-root", "private": true, "type": "module" }\n' > package.json

cat > packages/lib-dist/package.json <<'JSON'
{ "name": "@demo/lib-dist", "version": "0.0.0", "type": "module",
  "main": "./dist/index.js", "types": "./dist/index.d.ts",
  "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
  "files": ["dist", "src"] }
JSON
echo 'export function helperFromDistPkg(x: number): number { return x + 1; }' > packages/lib-dist/src/index.ts
echo 'export function helperFromDistPkg(x) { return x + 1; }'                 > packages/lib-dist/dist/index.js

cat > packages/lib-src/package.json <<'JSON'
{ "name": "@demo/lib-src", "version": "0.0.0", "type": "module",
  "main": "./src/index.ts", "types": "./src/index.ts",
  "exports": { ".": { "types": "./src/index.ts", "import": "./src/index.ts" } },
  "files": ["src"] }
JSON
echo 'export function helperFromSrcPkg(x: number): number { return x + 2; }' > packages/lib-src/src/index.ts

cat > apps/app/package.json <<'JSON'
{ "name": "@demo/app", "private": true, "type": "module",
  "dependencies": { "@demo/lib-dist": "workspace:*", "@demo/lib-src": "workspace:*" } }
JSON
cat > apps/app/src/main.ts <<'TS'
import { helperFromDistPkg } from '@demo/lib-dist';
import { helperFromSrcPkg } from '@demo/lib-src';
import { localHelper } from './local';

export function run(): number {
  return helperFromDistPkg(1) + helperFromSrcPkg(2) + localHelper(3);
}
TS
echo 'export function localHelper(x: number): number { return x * 2; }' > apps/app/src/local.ts

git init -q . && git add -A && git -c user.email=r@r -c user.name=r commit -qm init

codebase-memory-mcp cli index_repository --repo-path "$PWD" --name repro --mode full

Indexing correctly excludes the gitignored build output:

"excluded":{"dirs":[".git","packages/lib-dist/dist"],"count":2}

IMPORTS edges — @demo/lib-dist is missing, the other two are present:

codebase-memory-mcp cli query_graph --project repro \
  --query "MATCH (f:File)-[imp:IMPORTS]->(m) RETURN f.file_path, imp.local_name, m.qualified_name"

  apps/app/src/main.ts  helperFromSrcPkg  repro.packages.lib-src.src.index
  apps/app/src/main.ts  localHelper       repro.apps.app.src.local
total: 2

main.ts has three imports; only the two whose targets are indexable produce an edge.

CALLS edges — the cross-package call degrades to a 0.38 guess:

codebase-memory-mcp cli query_graph --project repro \
  --query "MATCH (a)-[r:CALLS]->(b) RETURN a.qualified_name, r.callee, b.qualified_name, r.strategy, r.confidence"

  repro.apps.app.src.main.run  helperFromDistPkg  repro.packages.lib-dist.src.helperFromDistPkg  unique_name    "0.38"
  repro.apps.app.src.main.run  helperFromSrcPkg   repro.packages.lib-src.src.helperFromSrcPkg    unique_name    "0.75"
  repro.apps.app.src.main.run  localHelper        repro.apps.app.src.local.localHelper           lsp_ts_import  "0.95"

Here the guess happens to land on the right symbol, because the dummy names are unique. In a real codebase they are not — that is #1355.

Secondary observation, reported as observed rather than diagnosed: in the control case (@demo/lib-src) the IMPORTS edge is created and points at repro.packages.lib-src.src.index, the very module that defines helperFromSrcPkg — yet the call still resolves via unique_name @ 0.75 rather than import_map @ 0.95. So a landed IMPORTS edge did not feed import_map here. That may be a second, separate gap, or an artifact of this repro's size; I have not reduced it further.

Project scale (if relevant)

Private TS pnpm monorepo: 321 files, 4,548 nodes / 9,822 edges, --mode full. 4 workspace packages (@cck/core, @cck/engine, @cck/tools, @cck/ecs-bitecs) consumed by 2 apps. nodeLinker: hoisted, moduleResolution: "Bundler", no tsconfig paths (workspace resolution only). Edge counts: CALLS 1,190, USAGE 2,841, IMPORTS 774 — the 774 are all relative specifiers.

Confirmations

Metadata

Metadata

Assignees

No one assigned

    Labels

    parsing/qualityGraph extraction bugs, false positives, missing edgesux/behaviorDisplay bugs, docs, adoption UXwindowsWindows-specific issues

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions