Moving a project off shamefully-hoist: which legacy build scripts break under pnpm's strict node_modules layout
0 reputation · 23 Apr 2020, 15:53 UTC
0 reputation · 23 Apr 2020, 15:53 UTC
We currently run pnpm with shamefully-hoist=true in .npmrc so that older tooling sees a flat, npm-style node_modules. The goal is to remove that setting and rely on pnpm's default symlinked layout, where only declared dependencies in package.json are resolvable at the project root and everything else lives under node_modules/.pnpm.
The uncertainty is scope: some build scripts and test utilities in the repo appear to import packages that are only present transitively, which works today purely because of hoisting. pnpm's documented behavior is that strict resolution will refuse those imports, but it is not clear how to inventory every offending import before flipping the flag, or whether the failure mode is always a clean resolution error at install/build time versus a runtime failure later.
Assuming a recent pnpm version (9.x) on a single-package project, not a workspace:
shamefully-hoist?.pnpm/node_modules via hoist-pattern) that keeps the strict root layout while satisfying tools that expect a flat structure?35275 reputation · 24 Apr 2020, 01:28 UTC
1. Auditing phantom imports: pnpm has no single command that lists every undeclared import. The recommended workflow is a “dry run” branch: remove shamefully-hoist=true, delete node_modules and pnpm-lock.yaml, run pnpm install and then execute the full build‑test‑lint pipeline. Any resolution errors that surface are the real‑world list of phantom dependencies. For a static pre‑flight, run depcheck, knip or eslint‑plugin‑import's no‑extraneous‑dependencies rule; these tools flag imports that are not present in package.json but may produce false positives for dynamic require() calls.
2. Middle‑ground hoisting: pnpm offers hoist-pattern (default: node_modules/.pnpm/node_modules) and public-hoist-pattern. The latter is the direct replacement for shamefully-hoist=true and allows you to hoist only the packages you actually need into the root node_modules. For example:
# .npmrc
public-hoist-pattern[]=*eslint*
public-hoist-pattern[]=*prettier*
This keeps the root layout strict while satisfying tools that still expect a flat structure.
3. Tool categories that usually require public‑hoist‑pattern: prettier-plugin-foo) loaded via require() in a prettier.config.js.@types/lodash) when the compiler is run from a script that imports them via require().
In most cases the fix is to declare the package explicitly in package.json (as a devDependency if it’s only needed for tooling). Hoisting should be reserved for the handful of tools that cannot be fixed by a direct import.
Branch off the current state and edit .npmrc to remove shamefully-hoist=true.
Delete node_modules and pnpm-lock.yaml to force a clean install.
Run pnpm install and note any errors that mention “cannot find module …” or “extraneous dependency”.
Run the full CI pipeline locally (build, test, lint). Record any failures that occur at runtime or in the test runner.
Run a static audit: npx depcheck --skip-unused --skip-dev or npx knip. Compare the list with the runtime failures; any overlap is a candidate for a public-hoist-pattern or a new devDependency.
For each tool that still fails after adding the missing devDependencies, add a targeted public-hoist-pattern[] entry as shown above.
Commit the changes, delete node_modules again, run pnpm install and verify that the build passes.
If your project uses a lot of require('plugin-name') calls where the plugin name is constructed at runtime, you may need a more permissive public-hoist-pattern or a custom resolver. Please let us know if you have such dynamic loading patterns.
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.