You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
This repository was archived by the owner on May 14, 2026. It is now read-only.
hoistPattern and publicHoistPattern decide which transitive dependencies are also surfaced outside the isolated <virtual_store>/<pkg>/node_modules/<pkg> shape — into the project's flat node_modules/.pnpm/node_modules/ (private hoist) or directly into <project>/node_modules/ (public hoist). They are the tail of pnpm's "almost-strict node_modules" compromise: the strict layout breaks ecosystem packages that rely on phantom dependencies, so pnpm hoists by default (hoistPattern: ["*"]) and additionally surfaces editor-tooling families (publicHoistPattern: ["*eslint*", "*prettier*"]) into the root. Roadmap (#299) lists "Hoisting (hoistPattern, publicHoistPattern)" as a remaining Stage-1 item.
Pacquet's config plumbing for the patterns is in place but no part of the installer acts on them. End result: a pacquet-installed project has a strict node_modules with no hoisting at all, which silently breaks any consumer that expected require('eslint-config-...') to resolve from the project root, or any phantom-dep-tolerant package buried under a transitive.
getHoistedDependencies walks the dep graph BFS starting from each importer's direct deps, collecting { alias, depPath } pairs. First-seen wins per alias (highest in the tree).
createGetAliasHoistType runs each alias through publicMatcher first, then privateMatcher (via @pnpm/config.matcher). publicHoistPattern wins ties.
symlinkHoistedDependencies creates <publicHoistedModulesDir>/<alias> and <privateHoistedModulesDir>/<alias> symlinks to the existing package dir under the virtual store.
linkAllBins(privateHoistedModulesDir) populates .bin/. Public-hoist bins are linked alongside the project's direct deps elsewhere (matters for resolution order).
Where it lives.privateHoistedModulesDir = <virtualStoreDir>/node_modules (or <rootModulesDir>/.pnpm/node_modules under GVS, installing/deps-restorer/src/index.ts:228-232). publicHoistedModulesDir = <rootModulesDir> itself.
When it runs. After all snapshots are imported and the regular direct-dep symlinks are written, but before bin-linking and lifecycle scripts. The call site is installing/deps-restorer/src/index.ts:471-486, guarded by (opts.hoistPattern != null || opts.publicHoistPattern != null). null (not []) means "feature disabled."
What gets persisted.newHoistedDependencies (a Record<DepPath, Record<alias, 'private' | 'public'>>) is merged with the existing hoistedDependencies from .modules.yaml and written back. The persisted map keeps later installs from re-hoisting the same aliases differently.
Matcher syntax.config/matcher implements a simple glob-like syntax: * wildcards, optional ! prefix for negation, first-match-wins with ignore-precedence rules. Patterns match against the alias (the dep's name in its parent's dependencies map), not the package name on disk.
shamefullyHoist legacy. When shamefullyHoist: true, upstream sets publicHoistPattern = ['*']. Pacquet already mirrors this fallback in crates/modules-yaml/src/lib.rs:465.
CI override.extendInstallOptions.ts:337 forces publicHoistPattern = [] when enableGlobalVirtualStore is on and the user didn't set it, since GVS already publishes packages globally and the public-hoist contract changes. Not relevant for the no-GVS path; track with Add global virtual store support to pacquet install --frozen-lockfile #432.
Today
Pacquet has the config but no algorithm.
crates/config/src/lib.rs:127-153 exposes hoist: bool, hoist_pattern: Vec<String> (defaults ["*"]), public_hoist_pattern: Vec<String> (defaults ["*eslint*", "*prettier*"]), and shamefully_hoist: bool. Defaults match upstream.
crates/config/src/workspace_yaml.rs:47-50 parses all four fields from pnpm-workspace.yaml.
crates/modules-yaml/src/lib.rs:155 deserializes hoisted_dependencies: BTreeMap<String, BTreeMap<String, HoistKind>>. apply_legacy_shamefully_hoist at :460 handles the legacy shamefullyHoist migration on read.
crates/package-manager/src/install.rs:254-268 populates .modules.yaml with hoist_pattern and public_hoist_pattern but explicitly notes hoistedDependencies is left empty: "Fields pacquet does not populate yet (hoistedDependencies, …) default to empty / unset, which is exactly what upstream produces for a single-importer install with no skipped optional deps and no build allowlist." That comment is wrong for hoisting — upstream produces a populated map whenever hoistPattern or publicHoistPattern is non-null.
crates/package-manager/src/deps_graph.rs:30-66 already builds a DepsGraph and explicitly flags hoisting as an out-of-scope upstream use: "Pacquet only uses the graph for cache hashing today." The graph is the input upstream uses for getHoistedDependencies; reuse it rather than building a second walker.
So: types, config plumbing, and .modules.yaml persistence are ready. The algorithm, the symlink writes, the bin links, and the .modules.yaml write of hoistedDependencies are not.
Plan
A. Matcher
Port @pnpm/config.matcher into pacquet, probably as a small matcher module in crates/config (or its own crate if other call sites appear). Match upstream's semantics exactly:
New module under crates/package-manager — hoist.rs — porting getHoistedDependencies + symlinkHoistedDependencies from installing/linking/hoist/src/index.ts. Reuse deps_graph::build_deps_graph output as the input graph; do not build a second walker.
BFS from each importer's direct dependencies (directDepsByImporterId in upstream). Pacquet's importer plumbing already iterates Lockfile.importers, but the walk needs the direct-deps map per importer. Workspace install (Add workspace support to pacquet install --frozen-lockfile #431) widens this from one importer to many; design the function to take a directDepsByImporter: HashMap<ImporterId, HashMap<alias, depPath>> from day one.
First-seen-wins per alias. Track visited (parent, alias) edges so the BFS is bounded by the graph size.
For each (alias, depPath), run publicMatcher(alias) then privateMatcher(alias). Public wins.
Create <privateHoistedModulesDir>/<alias> → <virtual_store>/<dep_dir>/node_modules/<pkg_name> symlinks. privateHoistedModulesDir is <virtual_store_dir>/node_modules. Skip the symlink if the alias is also a direct dependency (directDeps of '.' per hoist/src/index.ts:117-122 — getHoistedDependencies filters these out before symlinking).
Create <publicHoistedModulesDir>/<alias> → same target. publicHoistedModulesDir is <project_root>/node_modules. Skip when the alias already exists as a direct-dep symlink in node_modules — same precedence rule as upstream.
Run linkAllBins(privateHoistedModulesDir) after symlinks: scan each privately-hoisted package's package.json#bin, write .bin/<name> cmd-shims into <privateHoistedModulesDir>/.bin/. Pacquet's pacquet-cmd-shim already has LinkBins for the direct-deps path — extend or call it. Public hoist bins are not linked here; they're picked up by the existing direct-dep bin-linking pass because they're under the same node_modules.
Call the new hoist pass from Install::run after symlinking direct deps and before pnpm:summary. Guard with config.hoist_pattern.is_some() || config.public_hoist_pattern.is_some() (i.e. the field, not the Vec length — empty is a valid disabled state for one pattern but not the other; mirror upstream's != null check).
Pass the resulting HoistedDependencies map into build_modules_manifest (crates/package-manager/src/install.rs:259) so .modules.yaml's hoistedDependencies reflects reality. Update the misleading comment at install.rs:254-258.
Install with default patterns: assert <project>/node_modules/.pnpm/node_modules/<every-transitive-alias> symlinks exist; assert at least one eslint or prettier transitive is publicly hoisted to <project>/node_modules/<alias>.
Install with hoistPattern: [] (private hoist disabled, public still default): only public-hoist symlinks; .pnpm/node_modules/ has no extra entries beyond what's already there.
Install with both patterns empty: no hoist symlinks anywhere; node_modules only contains the project's direct deps.
Install with overlapping patterns (alias matches both public and private): goes public, not private.
Install with !-negation in hoistPattern: excluded alias is not privately hoisted.
.modules.yaml round-trips hoistedDependencies correctly (existing crates/modules-yaml tests should still pass; add one that asserts the write side after a real install rather than only deserialization).
Workspace install (Add workspace support to pacquet install --frozen-lockfile #431).getHoistedDependencies already accounts for multiple importers via directDepsByImporterId; design the new function to take a per-importer map from the start so the workspace land-day is a wiring change, not a rewrite.
nodeLinker: hoisted. A separate Stage-1 Roadmap checkbox. That mode uses a different code path (linkHoistedModules at installing/linking/hoisted-node-modules) and is not what this issue is about.
hoistWorkspacePackages and injected deps. Not Stage-1; track with workspace follow-ups.
Background
hoistPatternandpublicHoistPatterndecide which transitive dependencies are also surfaced outside the isolated<virtual_store>/<pkg>/node_modules/<pkg>shape — into the project's flatnode_modules/.pnpm/node_modules/(private hoist) or directly into<project>/node_modules/(public hoist). They are the tail of pnpm's "almost-strict node_modules" compromise: the strict layout breaks ecosystem packages that rely on phantom dependencies, so pnpm hoists by default (hoistPattern: ["*"]) and additionally surfaces editor-tooling families (publicHoistPattern: ["*eslint*", "*prettier*"]) into the root. Roadmap (#299) lists "Hoisting (hoistPattern, publicHoistPattern)" as a remaining Stage-1 item.Pacquet's config plumbing for the patterns is in place but no part of the installer acts on them. End result: a
pacquet-installed project has a strict node_modules with no hoisting at all, which silently breaks any consumer that expectedrequire('eslint-config-...')to resolve from the project root, or any phantom-dep-tolerant package buried under a transitive.What upstream does
References at pnpm v11
94240bc046:installing/linking/hoist/src/index.ts:getHoistedDependencieswalks the dep graph BFS starting from each importer's direct deps, collecting{ alias, depPath }pairs. First-seen wins per alias (highest in the tree).createGetAliasHoistTyperuns each alias throughpublicMatcherfirst, thenprivateMatcher(via@pnpm/config.matcher).publicHoistPatternwins ties.symlinkHoistedDependenciescreates<publicHoistedModulesDir>/<alias>and<privateHoistedModulesDir>/<alias>symlinks to the existing package dir under the virtual store.linkAllBins(privateHoistedModulesDir)populates.bin/. Public-hoist bins are linked alongside the project's direct deps elsewhere (matters for resolution order).privateHoistedModulesDir = <virtualStoreDir>/node_modules(or<rootModulesDir>/.pnpm/node_modulesunder GVS,installing/deps-restorer/src/index.ts:228-232).publicHoistedModulesDir = <rootModulesDir>itself.installing/deps-restorer/src/index.ts:471-486, guarded by(opts.hoistPattern != null || opts.publicHoistPattern != null).null(not[]) means "feature disabled."newHoistedDependencies(aRecord<DepPath, Record<alias, 'private' | 'public'>>) is merged with the existinghoistedDependenciesfrom.modules.yamland written back. The persisted map keeps later installs from re-hoisting the same aliases differently.config/matcherimplements a simple glob-like syntax:*wildcards, optional!prefix for negation, first-match-wins with ignore-precedence rules. Patterns match against the alias (the dep's name in its parent'sdependenciesmap), not the package name on disk.shamefullyHoistlegacy. WhenshamefullyHoist: true, upstream setspublicHoistPattern = ['*']. Pacquet already mirrors this fallback incrates/modules-yaml/src/lib.rs:465.extendInstallOptions.ts:337forcespublicHoistPattern = []whenenableGlobalVirtualStoreis on and the user didn't set it, since GVS already publishes packages globally and the public-hoist contract changes. Not relevant for the no-GVS path; track with Add global virtual store support topacquet install --frozen-lockfile#432.Today
Pacquet has the config but no algorithm.
crates/config/src/lib.rs:127-153exposeshoist: bool,hoist_pattern: Vec<String>(defaults["*"]),public_hoist_pattern: Vec<String>(defaults["*eslint*", "*prettier*"]), andshamefully_hoist: bool. Defaults match upstream.crates/config/src/workspace_yaml.rs:47-50parses all four fields frompnpm-workspace.yaml.crates/modules-yaml/src/lib.rs:155deserializeshoisted_dependencies: BTreeMap<String, BTreeMap<String, HoistKind>>.apply_legacy_shamefully_hoistat:460handles the legacyshamefullyHoistmigration on read.crates/package-manager/src/install.rs:254-268populates.modules.yamlwithhoist_patternandpublic_hoist_patternbut explicitly noteshoistedDependenciesis left empty: "Fields pacquet does not populate yet (hoistedDependencies, …) default to empty / unset, which is exactly what upstream produces for a single-importer install with no skipped optional deps and no build allowlist." That comment is wrong for hoisting — upstream produces a populated map wheneverhoistPatternorpublicHoistPatternis non-null.crates/package-manager/src/deps_graph.rs:30-66already builds aDepsGraphand explicitly flags hoisting as an out-of-scope upstream use: "Pacquet only uses the graph for cache hashing today." The graph is the input upstream uses forgetHoistedDependencies; reuse it rather than building a second walker.So: types, config plumbing, and
.modules.yamlpersistence are ready. The algorithm, the symlink writes, the bin links, and the.modules.yamlwrite ofhoistedDependenciesare not.Plan
A. Matcher
@pnpm/config.matcherinto pacquet, probably as a smallmatchermodule incrates/config(or its own crate if other call sites appear). Match upstream's semantics exactly:*wildcard,?not supported.!-prefix ignore patterns.config/matcher/src/index.ts:43-58(hasIgnore/hasIncludecombinations).config/matcher/test/index.ts.B. Hoist algorithm
crates/package-manager—hoist.rs— portinggetHoistedDependencies+symlinkHoistedDependenciesfrominstalling/linking/hoist/src/index.ts. Reusedeps_graph::build_deps_graphoutput as the input graph; do not build a second walker.directDepsByImporterIdin upstream). Pacquet's importer plumbing already iteratesLockfile.importers, but the walk needs the direct-deps map per importer. Workspace install (Add workspace support topacquet install --frozen-lockfile#431) widens this from one importer to many; design the function to take adirectDepsByImporter: HashMap<ImporterId, HashMap<alias, depPath>>from day one.(parent, alias)edges so the BFS is bounded by the graph size.(alias, depPath), runpublicMatcher(alias)thenprivateMatcher(alias). Public wins.HoistedDependenciesmap shape pacquet'scrates/modules-yamlalready expects (BTreeMap<DepPath, BTreeMap<alias, HoistKind>>).C. Symlinks and bins
<privateHoistedModulesDir>/<alias>→<virtual_store>/<dep_dir>/node_modules/<pkg_name>symlinks.privateHoistedModulesDiris<virtual_store_dir>/node_modules. Skip the symlink if the alias is also a direct dependency (directDepsof'.'perhoist/src/index.ts:117-122—getHoistedDependenciesfilters these out before symlinking).<publicHoistedModulesDir>/<alias>→ same target.publicHoistedModulesDiris<project_root>/node_modules. Skip when the alias already exists as a direct-dep symlink innode_modules— same precedence rule as upstream.linkAllBins(privateHoistedModulesDir)after symlinks: scan each privately-hoisted package'spackage.json#bin, write.bin/<name>cmd-shims into<privateHoistedModulesDir>/.bin/. Pacquet'spacquet-cmd-shimalready hasLinkBinsfor the direct-deps path — extend or call it. Public hoist bins are not linked here; they're picked up by the existing direct-dep bin-linking pass because they're under the samenode_modules.BINARIES_CONFLICTwarnings (upstream does athoist/src/index.ts:145-149).D. Wiring + persistence
Install::runafter symlinking direct deps and beforepnpm:summary. Guard withconfig.hoist_pattern.is_some() || config.public_hoist_pattern.is_some()(i.e. the field, not theVeclength — empty is a valid disabled state for one pattern but not the other; mirror upstream's!= nullcheck).HoistedDependenciesmap intobuild_modules_manifest(crates/package-manager/src/install.rs:259) so.modules.yaml'shoistedDependenciesreflects reality. Update the misleading comment atinstall.rs:254-258..modules.yaml, treat that as a "needs full re-hoist" signal — upstream forces re-import indeps-restorer/src/index.ts:357. Out of scope for this issue if partial install (Partial install with--frozen-lockfile: read+writenode_modules/.pnpm/lock.yamland skip unchanged snapshots #433) hasn't landed; capture as a follow-up checkbox.E. Tests
<project>/node_modules/.pnpm/node_modules/<every-transitive-alias>symlinks exist; assert at least one eslint or prettier transitive is publicly hoisted to<project>/node_modules/<alias>.hoistPattern: [](private hoist disabled, public still default): only public-hoist symlinks;.pnpm/node_modules/has no extra entries beyond what's already there.node_modulesonly contains the project's direct deps.!-negation inhoistPattern: excluded alias is not privately hoisted.shamefullyHoist: true(legacy) →publicHoistPattern: ['*']behavior..modules.yamlround-tripshoistedDependenciescorrectly (existingcrates/modules-yamltests should still pass; add one that asserts the write side after a real install rather than only deserialization).installing/deps-installer/test/install/hoist.tsperplans/TEST_PORTING.md.Interactions / out of scope
pacquet install --frozen-lockfile#432).privateHoistedModulesDirmoves from<virtualStoreDir>/node_modulesto<rootModulesDir>/.pnpm/node_moduleswhen GVS is on, and upstream forcespublicHoistPattern = []if the user didn't set one (extendInstallOptions.ts:337). Track those bits with the GVS issue.pacquet install --frozen-lockfile#431).getHoistedDependenciesalready accounts for multiple importers viadirectDepsByImporterId; design the new function to take a per-importer map from the start so the workspace land-day is a wiring change, not a rewrite.--frozen-lockfile: read+writenode_modules/.pnpm/lock.yamland skip unchanged snapshots #433). Pattern-change detection in.modules.yamlis what tells the partial-install path to re-hoist. Until Partial install with--frozen-lockfile: read+writenode_modules/.pnpm/lock.yamland skip unchanged snapshots #433 lands, every install does the full hoist pass.nodeLinker: hoisted. A separate Stage-1 Roadmap checkbox. That mode uses a different code path (linkHoistedModulesatinstalling/linking/hoisted-node-modules) and is not what this issue is about.hoistWorkspacePackagesandinjecteddeps. Not Stage-1; track with workspace follow-ups.Related
pacquet install --frozen-lockfile#432pacquet install --frozen-lockfile#431--frozen-lockfile: read+writenode_modules/.pnpm/lock.yamland skip unchanged snapshots #433Written by an agent (Claude Code, claude-opus-4-7).