Skip to content

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

Closed

Description

@elderbernardi

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.yaml with packages/*, 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 -u

Actual 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/core resolves to packages/core/src/index.ts (from exports["."], else main), and @acme/core/schema resolves to packages/core/src/schema/index.ts (from exports["./schema"]).
  • callers caseStatus for the packages/core definition lists apps/web/lib/list.ts, apps/worker/src/run.ts and packages/db/src/load.ts.
  • affected for packages/core/src/status.ts lists apps/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 imports edges from consumers to workspace packages, so affected cannot follow them;
  • subpath exports (@scope/db/schema → ./src/schema/index.ts) never resolve, because resolveWorkspaceImport maps @scope/db/schema to <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:

  1. While loading npm/yarn/bun/pnpm members (loadWorkspacePackages), read each member's package.json and record:

    • the bare entry: exports["."], or exports when it is a string, else main, else module;
    • a subpath map from the other exports keys ("./schema" → ./src/schema/index.ts), including "./*" patterns.

    For conditional targets ({ "types": …, "import": …, "default": … }), pick the first target that exists on disk, preferring types, import, default, require in that order. Normalize to projectRoot-relative posix paths, as readOhpmMain does.

  2. In resolveWorkspaceImport: a bare import returns the recorded entry (as ohpm does today). A subpath import checks the subpath map before falling back to dir + subpath.

  3. Optional, for packages whose exports point 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.

  4. Tests in __tests__/resolution.test.ts:

    • entry via main only;
    • 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.

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

  1. colbymchenry commented on Oct 22, 2026

    @colbymchenry
    Owner

    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 its package.json names: exports first, with its conditions, subpaths and ./* patterns, then main, module, types or source. So import { caseStatus } from '@acme/core' reaches src/status.ts, and the private caseStatus no longer takes its callers.

    When an entry names build output that isn't indexed, like ./dist/index.mjs, CodeGraph follows the matching file under src/ if there is one, and links nothing otherwise. codegraph sync picks up edits to those fields. It ships in the next release.

  2. added a commit that references this issue on Oct 22, 2026
    e8ddc4e
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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