Repository navigation
TypeScript/pnpm: workspace member imports fail when the entry is declared via package.json "main"/"exports" (e.g. ./src/index.ts) — a same-name private function then hides real callers #2502
Description
Summary
In a pnpm workspace, an import of a member package (import { x } from '@acme/core') is recorded as failed when the member declares its entry through package.json main / exports and has no index.* at the package root. The common TypeScript monorepo layout is "main": "./src/index.ts", "exports": { ".": "./src/index.ts" }.
Calls into the package then resolve only through bare-name matching. That holds while the name is unique in the repo. Once a private function with the same name exists anywhere, the matcher picks by path proximity, and every consumer that is closer to the homonym loses its edge. The result is a false, incomplete callers / impact, and affected misses the tests.
The code says this is a known v1 limitation (workspace-packages.ts L17–L24: "it does NOT yet honour a member's exports map or main field"). I could not find an open issue for it, so here is a repro, the real-world impact and a proposed fix.
Environment
- codegraph 1.6.2 (npm,
@colbymchenry/codegraph@1.6.2) - Linux x86_64, Node 22
- pnpm workspace (
pnpm-workspace.yamlwithpackages/*,apps/*)
Minimal reproduction
All names are invented. The script builds a git repo in a temp dir, indexes it and queries it.
repro.sh
#!/usr/bin/env bash
# Reproduces: pnpm workspace member import is not resolved when the member's
# entry is declared via package.json "main"/"exports" (e.g. ./src/index.ts).
set -euo pipefail
CG=${CG:-codegraph}
R=$(mktemp -d)/repro && mkdir -p "$R" && cd "$R"
mkdir -p packages/core/src packages/db/src apps/web/lib apps/worker/src
echo '{ "name": "repro", "private": true }' > package.json
printf 'packages:\n - "packages/*"\n - "apps/*"\n' > pnpm-workspace.yaml
cat > packages/core/package.json <<'J'
{ "name": "@acme/core", "type": "module", "main": "./src/index.ts", "exports": { ".": "./src/index.ts" } }
J
echo "export * from './status.js';" > packages/core/src/index.ts
cat > packages/core/src/status.ts <<'T'
export function caseStatus(input: { open: boolean }): { value: string } {
return { value: input.open ? 'open' : 'closed' };
}
T
echo '{ "name": "@acme/db", "type": "module" }' > packages/db/package.json
cat > packages/db/src/load.ts <<'T'
import { caseStatus } from '@acme/core';
export const loadStatus = (open: boolean) => caseStatus({ open }).value;
T
echo '{ "name": "@acme/web", "type": "module" }' > apps/web/package.json
cat > apps/web/lib/list.ts <<'T'
import { caseStatus } from '@acme/core';
export const listCases = (cases: { open: boolean }[]) => cases.map((c) => caseStatus(c).value);
T
echo '{ "name": "@acme/worker", "type": "module" }' > apps/worker/package.json
cat > apps/worker/src/run.ts <<'T'
import { caseStatus } from '@acme/core';
export const run = () => caseStatus({ open: true }).value;
T
# A private helper that happens to share the name (exported module, so not "sealed").
cat > apps/worker/src/interpret.ts <<'T'
function caseStatus(raw: string): string { return raw.trim(); }
export const interpret = (raw: string) => caseStatus(raw);
T
cat > apps/web/lib/list.test.ts <<'T'
import { listCases } from './list';
listCases([{ open: true }]);
T
# Subpath export: "@acme/core/schema" -> ./src/schema/index.ts
python3 - <<'P'
import json; f='packages/core/package.json'; d=json.load(open(f))
d['exports']['./schema']='./src/schema/index.ts'; json.dump(d,open(f,'w'))
P
mkdir -p packages/core/src/schema
echo "export const caseTable = 'case';" > packages/core/src/schema/index.ts
cat > apps/web/lib/table.ts <<'T'
import { caseTable } from '@acme/core/schema';
export const tableName = () => caseTable;
T
git init -q && git add -A && git -c user.email=r@r -c user.name=r commit -qm init
"$CG" init --yes >/dev/null 2>&1
echo "== callers of caseStatus"
"$CG" callers caseStatus
echo "== failed refs"
node -e "
const {DatabaseSync}=require('node:sqlite');
const db=new DatabaseSync('.codegraph/codegraph.db');
for (const r of db.prepare(\"select file_path,reference_kind,reference_name from unresolved_refs where status='failed' and (reference_name like '@acme/%' or reference_name in ('caseStatus','caseTable')) order by file_path\").all())
console.log(r.file_path, r.reference_kind, r.reference_name);"
echo "== affected tests for a change in packages/core/src/status.ts (expected: apps/web/lib/list.test.ts)"
echo packages/core/src/status.ts | "$CG" affected --stdin --quiet || true
echo "== workaround check: add packages/core/index.ts re-exporting ./src"
echo "export * from './src/index';" > packages/core/index.ts
"$CG" index --force --quiet >/dev/null 2>&1
"$CG" callers caseStatus | grep -E '^\s+(apps|packages)/' | sort -uActual output (1.6.2)
== callers of caseStatus
Callers of "caseStatus" — 2 distinct definitions (narrow with --file):
function caseStatus (typescript) — apps/worker/src/interpret.ts:1 (1):
function interpret
apps/worker/src/interpret.ts:2
function caseStatus (typescript) — packages/core/src/status.ts:1 (2):
function loadStatus
packages/db/src/load.ts:2
file load.ts [imports]
packages/db/src/load.ts:1
== failed refs
apps/web/lib/list.ts imports @acme/core
apps/web/lib/list.ts imports caseStatus
apps/web/lib/list.ts calls caseStatus
apps/web/lib/table.ts imports @acme/core/schema
apps/worker/src/run.ts imports @acme/core
apps/worker/src/run.ts imports caseStatus
apps/worker/src/run.ts calls caseStatus
packages/db/src/load.ts imports @acme/core
== affected tests for a change in packages/core/src/status.ts (expected: apps/web/lib/list.test.ts)
(nothing)
Expected
@acme/coreresolves topackages/core/src/index.ts(fromexports["."], elsemain), and@acme/core/schemaresolves topackages/core/src/schema/index.ts(fromexports["./schema"]).callers caseStatusfor thepackages/coredefinition listsapps/web/lib/list.ts,apps/worker/src/run.tsandpackages/db/src/load.ts.affectedforpackages/core/src/status.tslistsapps/web/lib/list.test.ts.
Confirmation of the cause
Adding packages/core/index.ts with export * from './src/index' (the directory-index path the resolver already tries) makes every edge resolve, homonym included. The last block of the script shows it.
| Variant | @acme/core import |
Callers of the core caseStatus |
|---|---|---|
Homonym present, entry via main/exports |
failed | packages/db only (it is the nearest by path) |
| Homonym removed | failed | all three, but only through unique-name matching |
node_modules symlinks added (as after pnpm install) |
failed | packages/db only |
Root index.ts added |
resolved | all three |
Named re-export vs export * in the barrel makes no difference, and neither does defining the function directly in src/index.ts.
Why it matters
On a private pnpm monorepo (about 1.1k TS files, 8 packages, every one declaring "main"/"exports" as ./src/index.ts), callers of a central domain function missed 10 of 36 caller files. A worker app had a private helper with the same name, so the web app's files lost their edges. For impact analysis, a silent "fewer callers" is the costly failure: a reviewer or agent concludes that a change is contained when it is not.
The same root cause also means:
- no file-level
importsedges from consumers to workspace packages, soaffectedcannot follow them; - subpath exports (
@scope/db/schema→./src/schema/index.ts) never resolve, becauseresolveWorkspaceImportmaps@scope/db/schemato<db>/schema, which does not exist.
Proposed fix
The resolver already has the hook: entryByName is filled for ArkTS/ohpm members from their main (L67–L78) and used for bare imports in resolveWorkspaceImport. Proposal:
-
While loading npm/yarn/bun/pnpm members (
loadWorkspacePackages), read each member'spackage.jsonand record:- the bare entry:
exports["."], orexportswhen it is a string, elsemain, elsemodule; - a subpath map from the other
exportskeys ("./schema"→./src/schema/index.ts), including"./*"patterns.
For conditional targets (
{ "types": …, "import": …, "default": … }), pick the first target that exists on disk, preferringtypes,import,default,requirein that order. Normalize to projectRoot-relative posix paths, asreadOhpmMaindoes. - the bare entry:
-
In
resolveWorkspaceImport: a bare import returns the recorded entry (as ohpm does today). A subpath import checks the subpath map before falling back todir + subpath. -
Optional, for packages whose
exportspoint at build output (./dist/index.js) that is not in the graph: try the mirrored source path (dist/→src/,.js→.ts/.tsx) before giving up. Keep the directory-index fallback as the last resort. -
Tests in
__tests__/resolution.test.ts:- entry via
mainonly; - entry via
exports["."](string and conditional); - a subpath export;
- a
"./*"pattern; - the homonym case above, asserting that every consumer resolves to the member and not to the private function.
- entry via
Related: #1719 (workspace import landing on a same-name local; the sealed-module guard from #1746 does not cover this case, because interpret.ts exports something), #2455 and #2456 (package import fails and the call falls back to name matching).
I'm happy to test a branch against the private repo and report edge counts before and after.
Activity
fix(resolution): a workspace package import resolves to its package.j…
fix(resolution): a workspace package import resolves to its package.j…
colbymchenry commentedon Oct 22, 2026
Thanks for the clear repro, and for pointing straight at the limitation in
workspace-packages.ts.#2799 is merged to
main. A workspace member's import now lands on the file itspackage.jsonnames:exportsfirst, with its conditions, subpaths and./*patterns, thenmain,module,typesorsource. Soimport { caseStatus } from '@acme/core'reachessrc/status.ts, and the privatecaseStatusno longer takes its callers.When an entry names build output that isn't indexed, like
./dist/index.mjs, CodeGraph follows the matching file undersrc/if there is one, and links nothing otherwise.codegraph syncpicks up edits to those fields. It ships in the next release.fix(js,ts): imports of a repo's own packages follow their package.jso…
fix(rust): workspace package entries, path dependencies and import sp…