Summary
Imports of a workspace package subpath — @scope/pkg/components/Icon — are not resolved, because the importing package's dependency is never mapped to the target package's exports map. On a 21-package pnpm monorepo this drops 323 import edges from 1,233 workspace imports.
Related to #3486 but independent: that one is a suffix-rewriting bug in relative resolution, this one is a resolution path that does not exist. Both are silent, and both bias affected and any reachability pass the same way.
Reproduction
packages/ui/package.json
{ "name": "@acme/ui",
"exports": { "./components/Icon": { "default": "./src/components/Icon.tsx" } } }
packages/ui/src/components/Icon.tsx export function Icon() {}
apps/web/src/Page.tsx import { Icon } from "@acme/ui/components/Icon";
Expected: Page.tsx → packages/ui/src/components/Icon.tsx. Actual: no edge; Icon.tsx has zero inbound.
The bare-root form (import { x } from "@acme/ui") has the same problem when the package uses exports rather than main.
Three details that matter for a fix
1. types must not be used as the target. The near-universal shape is:
"./components/Icon": {
"types": "./dist/components/Icon.web.d.ts",
"react-native": "./src/components/Icon.native.tsx",
"default": "./src/components/Icon.web.tsx"
}
types points into dist/, which is build output and normally excluded from the corpus, so resolving to it creates an edge to a node that does not exist.
2. The condition depends on the importer, not the package. An importer under a React Native app resolves react-native; a web importer resolves default. Picking one globally attributes Icon.web.tsx to mobile code that never imports it. Picking all of them everywhere inflates fan-in. Cheapest correct-enough rule is to choose by the importing file's location/platform suffix and fall back to the union when it is ambiguous.
3. Wildcard targets may not exist on disk. "./controls/*": { "default": "./src/controls/*.tsx" } where the real files are BottomNav.native.tsx and BottomNav.web.tsx — the bundler resolves that through sourceExts/resolveExtensions. An exports target that does not exist should fall through to the platform-suffixed siblings rather than being dropped.
Node's own subpath matching applies: exact key first, then a single * with a fixed prefix and suffix.
Measured impact
21-package pnpm monorepo, 1,430 source files, 10,958 nodes / 19,266 edges:
|
Count |
| Workspace imports in source |
1,233 |
| Edges missing from the graph |
323 |
Combined with #3486, 539 edges — and the package-level architecture rollup is materially wrong as a result. Two examples after repairing both out-of-band:
| Package edge |
graphify |
actual |
apps/web → packages/ui |
41 |
150 |
apps/mobile → packages/ui |
47 |
98 |
Non-test .tsx files reading as zero-inbound fall from 44% to 14% once both gaps are closed.
This is the failure mode worth emphasising: a monorepo's most important edges are exactly the cross-package ones, and those are the ones going missing. The graph looks plausible — it just understates coupling to shared packages by 2-3×.
Note on paths
_read_tsconfig_aliases already handles compilerOptions.paths, which is why some monorepos see cross-package edges resolve. That covers repos aliasing source directly; it does not cover repos that depend on workspace packages by name and publish through exports, which is the pnpm-workspace default.
Happy to open a PR if the approach above sounds right.
Summary
Imports of a workspace package subpath —
@scope/pkg/components/Icon— are not resolved, because the importing package's dependency is never mapped to the target package'sexportsmap. On a 21-package pnpm monorepo this drops 323 import edges from 1,233 workspace imports.Related to #3486 but independent: that one is a suffix-rewriting bug in relative resolution, this one is a resolution path that does not exist. Both are silent, and both bias
affectedand any reachability pass the same way.Reproduction
Expected:
Page.tsx → packages/ui/src/components/Icon.tsx. Actual: no edge;Icon.tsxhas zero inbound.The bare-root form (
import { x } from "@acme/ui") has the same problem when the package usesexportsrather thanmain.Three details that matter for a fix
1.
typesmust not be used as the target. The near-universal shape is:typespoints intodist/, which is build output and normally excluded from the corpus, so resolving to it creates an edge to a node that does not exist.2. The condition depends on the importer, not the package. An importer under a React Native app resolves
react-native; a web importer resolvesdefault. Picking one globally attributesIcon.web.tsxto mobile code that never imports it. Picking all of them everywhere inflates fan-in. Cheapest correct-enough rule is to choose by the importing file's location/platform suffix and fall back to the union when it is ambiguous.3. Wildcard targets may not exist on disk.
"./controls/*": { "default": "./src/controls/*.tsx" }where the real files areBottomNav.native.tsxandBottomNav.web.tsx— the bundler resolves that throughsourceExts/resolveExtensions. An exports target that does not exist should fall through to the platform-suffixed siblings rather than being dropped.Node's own subpath matching applies: exact key first, then a single
*with a fixed prefix and suffix.Measured impact
21-package pnpm monorepo, 1,430 source files, 10,958 nodes / 19,266 edges:
Combined with #3486, 539 edges — and the package-level architecture rollup is materially wrong as a result. Two examples after repairing both out-of-band:
apps/web → packages/uiapps/mobile → packages/uiNon-test
.tsxfiles reading as zero-inbound fall from 44% to 14% once both gaps are closed.This is the failure mode worth emphasising: a monorepo's most important edges are exactly the cross-package ones, and those are the ones going missing. The graph looks plausible — it just understates coupling to shared packages by 2-3×.
Note on
paths_read_tsconfig_aliasesalready handlescompilerOptions.paths, which is why some monorepos see cross-package edges resolve. That covers repos aliasing source directly; it does not cover repos that depend on workspace packages by name and publish throughexports, which is the pnpm-workspace default.Happy to open a PR if the approach above sounds right.