We've used Nub's built-in phantom detector to scan the 10,000 most downloaded packages on npm for undeclared dependencies. The resulting database covers 791 packages, including 649 missing from @yarnpkg/extensions, which undergirds the global virtual stores of pnpm v12 and Aube.
The results are published as @nubjs/extensions.
What are package extensions?
Many packages in the npm ecosystem fail to properly declare their dependencies in package.json, especially peer dependencies. Due to flat layout algorithms used by npm and Yarn, these unsound configurations often went undiscovered for years. As pnpm, Nub, and other package managers have moved towards more disk-efficient isolated node_modules layouts, these issues are manifesting in the form of ERR_MODULE_NOT_FOUND errors.
Yarn encountered this problem with Plug'n'Play and worked around it with @yarnpkg/extensions—a hand-maintained list of packages and their associated undeclared dependencies. It was a game of whack-a-mole, but this list grew over time to include a range of widely used packages.
They look like this:
export const packageExtensions = [
[
'@nrwl/devkit@*',
{
dependencies: {
tslib: '*',
},
},
],
// ...
];More recently, this package has come to underpin the Aube and pnpm v12 global virtual stores.
Bun 1.4 shipped an opt-in global virtual store, but it ships without extension-based patching at all. Packages with undeclared dependencies can install successfully, only to fail with
MODULE_NOT_FOUNDat runtime.
The problem
Here's the problem: it's woefully incomplete and minimally maintained. There have been just a handful of updates in the last three years. In our scan of the top 10,000 packages, we found 649 additional packages with undeclared imports beyond the 142 already covered by Yarn's database, bringing the total to 791 packages.
| Database | Packages |
|---|---|
@yarnpkg/extensions@2.0.7 | 142 |
@nubjs/extensions@1.0.4 | 791 |
Not every undeclared import breaks an install, so the scan classes each package by the worst edge it carries:
| What the scan found | Packages | Breaks under a strict layout? |
|---|---|---|
| Declaration-file reference only | 341 | Yes, at type-check time. The package's .d.ts files import something it never declares, so tsc reports TS2307 inside them; with skipLibCheck the import silently becomes any instead. No unguarded runtime import. |
| Guarded load only | 114 | No error. The try/catch around the import turns the optional feature off. |
| Undeclared framework peer | 101 | Yes. The app already has react, typescript or expo-modules-core, but a strict linker cannot connect the two until the peer is declared. |
| Forgotten dependency | 104 | Yes, when that code path runs. The import is unguarded on the package's main entry graph; 25 have a reproduced failure under Yarn Plug'n'Play. |
Every entry ships as an optional peer, which installs nothing on its own; the 25 reproduced cases ship as dependencies.
Every package in the database is below, ranked by weekly downloads. A row lists the imports that package does not declare; hover one to see how it goes undeclared and what the rule adds.
Some rules are scoped to a version range, because the package fixed the phantom in a later release — redux-thunk@<=2.3.0 needs the rule, and redux-thunk 2.4.0 onward declares the peer itself. Those rules stay in the database, since a lockfile pinning an older version still needs them, but the table hides them by default: 95 of the 791 packages are covered only by rules their current release has moved past. Tick the box to see them, and a shown rule names the version it was fixed in.
| # | Package | Weekly downloads | Phantom dependencies |
|---|---|---|---|
| 2 | esbuild | 204M | |
| 4 | @babel/parser | 173M | |
| 6 | vite | 132M | |
| 7 | @typescript-eslint/types | 128M | |
| 8 | @eslint/eslintrc | 93M | |
| 9 | esprima | 80M | |
| 10 | vitest | 77M | |
| 11 | playwright-core | 76M | |
| 12 | gaxios | 76M | |
| 13 | sharp | 75M | |
| 14 | es-abstract | 72M | |
| 15 | typed-array-byte-offset | 60M | |
| 16 | typed-array-byte-length | 60M | |
| 17 | @tailwindcss/oxide | 59M | |
| 19 | @emnapi/core | 58M | |
| 20 | event-target-shim | 49M | |
| 21 | @testing-library/jest-dom | 46M | |
| 23 | eslint-module-utils | 45M | |
| 24 | eslint-plugin-import | 44M | |
| 25 | next | 43M | |
| 26 | jest-validate | 43M | |
| 27 | unplugin | 43M | |
| 28 | pg-connection-string | 42M | |
| 30 | recharts | 40M | |
| 32 | escodegen | 40M |
The new @nubjs/extensions package serves as a modern, 100%-compatible drop-in replacement.
- Every Yarn rule is preserved, including its dependency ranges and peer metadata.
- The export format is unchanged:
packageExtensions, an array of[selector, extension]pairs. - CommonJS and ESM are supported.
Other tools can adopt this with a one-line change:
import {
packageExtensions,
} from '@yarnpkg/extensions';
} from '@nubjs/extensions';Nub itself has incorporated this list into its resolution engine. Nub also statically analyzes source code to identify phantom dependencies on the fly. This phantom dependency finder in Nub is exactly what we used to generate the @nubjs/extensions data set. Our approach is detailed in Unblocking the global virtual store.
The package is updated autonomously via CI.
- Periodic refreshes of the npm download ranking to identify new or rising packages.
- Daily scans check current published package versions.
- Handwritten overrides are hard-coded and can be updated via PR. Some packages contain phantom dependencies that are not identifiable via static analysis.
npm install @nubjs/extensions