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.
Tracks everything pacquet needs to do to support pnpm's hoisted node-linker mode end to end. Today the value deserializes and round-trips into .modules.yaml, but no install code branches on it — pacquet install with nodeLinker: hoisted silently produces an isolated layout and a .modules.yaml that claims it is hoisted.
This is an umbrella, not a single PR. See Workstream for the independently shippable slices.
Upstream references pin pnpm/pnpmmain at 94240bc046.
Not to be confused with #435.hoistPattern and publicHoistPattern are isolated-linker settings; they are not consulted at all under nodeLinker: hoisted. The two features share nothing at install time — different hoisters, different on-disk shapes, different fields populated. Track that work in #435.
Goals
Correctness.pacquet install with nodeLinker: hoisted produces the same node_modules/ shape as pnpm — real directories at the project root, the hoist algorithm matching pnpm's choice of which version wins each top-level slot, conflicts placed under <parent>/node_modules/<conflict>.
.modules.yaml round-trip parity.hoistedDependencies and hoistedLocations written faithfully so subsequent installs honor the same plan and rebuild knows where each depPath lives.
Repeat-install semantics. Subsequent installs diff the old plan against the new and rimraf orphans, in the same order pnpm does.
Workspace semantics. In a workspace, each project's hoist plan is computed jointly with the others (one shared workspace-wide tree), not per-project independently.
Non-goals
PnP linker.nodeLinker: 'pnp' is out of scope.
pnpm deploy under hoisted. Quietly disables lockfile use upstream; pacquet has no deploy command yet.
Injected workspace packages.injectionTargetsByDepPath is populated but the re-mirror step is a separate code path.
pnpm install --filter subset under hoisted. Hoisting decisions must be globally consistent, so partial installs effectively don't exist under this linker. Worth noting but not implementing.
Upstream surface
Every claim cites a permalink at 94240bc046.
1. Conceptual model
Hoisted produces real directories at node_modules/<pkg>/, not symlinks. Conflicts nest under <parent>/node_modules/<conflict>. No node_modules/.pnpm/ virtual store; the only on-disk persistence besides the tree itself is .modules.yaml. Two data structures:
installing/deps-restorer/src/lockfileToHoistedDepGraph.ts. Takes the wanted lockfile (+ optional current); returns {graph, prevGraph, hierarchy, directDependenciesByImporterId, hoistedLocations, injectionTargetsByDepPath, symlinkedDirectDependenciesByImporterId}. prevGraph is what linkHoistedModules diffs against to find orphans.
Internally calls hoist(lockfile, …) (real-hoist), then fetchDeps walks the HoisterResult tree:
Refuses to fetch a package whose packageIsInstallable returns false (cpu/os/libc/engines) — adds to opts.skipped. Same Skipped set the isolated path uses.
The @yarnpkg/nm hoister is not pure pnpm code. For pacquet either (a) port @yarnpkg/nm/hoist to Rust, or (b) reimplement an equivalent name-conflict-aware tree-hoister with the same observable behavior (parent wins; respect peerNames; respect hoistingLimits). Either way, the interface into the hoister is what pacquet builds — the tree shape, the workspace-as-children trick, the peer-names knob, the external-dependencies trick.
hoistedDependencies: HoistedDependencies — the plan from real-hoist.
hoistedLocations: Record<string, string[]> — hoisted-linker only. Required by rebuild (MISSING_HOISTED_LOCATIONS) and by the skip-fetch optimization in lockfileToHoistedDepGraph.
nodeLinker: 'hoisted' — the persisted linker selection.
included: IncludedDependencies — validated against the current install; INCLUDED_DEPS_CONFLICT on mismatch.
hoistWorkspacePackages (default true) — under hoisted, workspace projects are always included in the hoist tree by virtue of being children of the virtual root.
crates/package-manager/src/install.rs:242-247 (map_node_linker), crates/modules-yaml/src/lib.rs:167 (field). Round-trip tests at crates/modules-yaml/tests/index.rs:25,82,86,....
3
.modules.yaml.hoistedDependencies field
Have (schema only)
Field at crates/modules-yaml/src/lib.rs:155; HoistKind enum at :303. Legacy hoistedAliases migrator at :461-482. Always written empty (build_modules_manifest at crates/package-manager/src/install.rs:259-278 leaves it ..Default::default(), with the doc-comment at :254-258 calling out the gap).
4
.modules.yaml.hoistedLocations field
Missing entirely
No field named hoisted_locations on Modules. (grep -rn "hoisted_locations" crates/ returns nothing in the struct schema.) This field is required for rebuild under hoisted.
5
Install pipeline branches on node_linker
Missing
grep -rn "NodeLinker::Hoisted" crates/ outside the enum + serde glue + the map_node_linker writer returns zero hits. Hoisted installs silently produce the isolated layout.
6
lockfileToHoistedDepGraph analog
Missing
No equivalent of the directory-keyed DependenciesGraph builder. crates/package-manager/src/deps_graph.rs is depPath-keyed and used only for cache hashing.
7
Hoist algorithm (real-hoist)
Missing
No port of @yarnpkg/nm/hoist or equivalent. No hoisting_limits or external_dependencies plumbing.
8
linkHoistedModules analog
Missing
All linking is symlink-based (create_virtual_store.rs + symlink_direct_dependencies.rs). No real-directory import path that overwrites with force: true.
9
Bin linking from a project-root node_modules
Partial
link_bins.rs:30-65 (link_direct_dep_bins) writes shims to <modules_dir>/.bin from direct-dep symlinks. The hoisted path needs to do this from real directories and per-parent <dir>/node_modules/.bin per hoist location.
10
--node-linker CLI flag
Missing
No node-linker / node_linker references in crates/cli/src/cli_args/. Setting requires pnpm-workspace.yaml or .npmrc.
11
Workspace support
Missing (foundationally)
crates/package-manager/src/install.rs:95-99 documents the absence. Workspaces are a prerequisite for hoisted full-parity, but a single-importer hoisted install is shippable first.
12
bundledDependencies extraction
Missing
Out of scope for this issue, but referenced for completeness — BUNDLED_DEPENDENCIES_WITHOUT_HOISTED is an upstream error gating this feature on hoisted.
In one sentence: pacquet has the deserialize-and-persist surface for hoisted; everything that produces the on-disk layout is missing.
Workstream
Slices are roughly ordered. Earlier slices unblock later ones.
Slice 1 — Real-directory linking primitive
Foundational. Hoisted's import step is "copy/hardlink the package's files into <parent>/node_modules/<alias>/, possibly overwriting an existing directory". Pacquet's existing primitives all symlink. This slice ports the equivalent of storeController.importPackage with keepModulesDir: true and force: true.
Scope:
New function in crates/package-manager/ (or a helper in crates/store-dir/ since it consumes the CAS) that takes a CAS file index + destination path and populates the destination as real files (matching pacquet's existing import method preference: hardlink, then clonefile, then copy).
Overwrite semantics: any pre-existing file at the destination is replaced. Pre-existing node_modules/ subdirectory is preserved (keepModulesDir: true) so nested deps don't get clobbered between the rimraf-orphans pass and the insert pass.
Stand-alone unit tests in the new module — does not yet need to be wired into the install pipeline.
Slice 2 — .modules.yaml.hoistedLocations field
Required for rebuild and for the "is the package already on disk" skip-fetch optimization.
Scope:
Add hoisted_locations: Option<BTreeMap<DepPath, Vec<String>>> to Modules (crates/modules-yaml/src/lib.rs).
Round-trip tests against the upstream schema.
Reader returns it from a freshly-deserialized .modules.yaml; writer serializes when non-empty.
Update the misleading comment at crates/package-manager/src/install.rs:254-258.
Slice 3 — Hoist algorithm port (single-importer)
The load-bearing slice.
Scope:
Reimplement an equivalent of @yarnpkg/nm/hoist in Rust, or port the upstream wrapper around it. Public surface matches upstream's hoist(lockfile, opts): input = the lockfile + root importer's direct deps + hoistingLimits + externalDependencies + per-package peerNames. Output = a tree of (alias, depPath, children) rooted at the project.
Defer multi-importer / workspace input — the function takes one importer for now but should be designed to take a Map<ImporterId, …> once workspaces land.
Defer hoistingLimits / externalDependencies knobs — accept empty inputs for now; wire them later when a real consumer appears.
Unit tests against fixtures derived from upstream's installing/linking/real-hoist/test/ cases.
Slice 4 — lockfileToHoistedDepGraph analog
Walks the hoist result and produces the directory-keyed graph that the link step consumes.
Diffs against any persisted hoistedDependencies/hoistedLocations to produce a prev_graph for Slice 6's orphan diff.
Slice 5 — link_hoisted_modules (the install-time linker)
Produces the on-disk tree from Slice 4's graph using Slice 1's primitive.
Scope:
Walk graph and prev_graph; rimraf surplus directories first (orphan removal), then import packages second. Match tryRemoveDir's EPERM/EBUSY tolerance.
For each graph node, call Slice 1's import primitive with force=true, keep_modules_dir=true.
Per-node_modules bin link: walk children, link each one's bins into <node_modules>/.bin. Per the upstream pass, this is one call per parent directory.
Skip-fetch optimization: when currentHoistedLocations[depPath] includes the target directory and<dir>/package.json exists, do not refetch from CAS.
Slice 6 — Pipeline branch and Install::run wiring
Plug Slices 3–5 into the install pipeline behind a node_linker == Hoisted branch.
Scope:
In Install::run (install.rs) and InstallFrozenLockfile::run (install_frozen_lockfile.rs), branch on config.node_linker:
NodeLinker::Isolated → existing path.
NodeLinker::Hoisted → Slice 3 (hoist) → Slice 4 (walk) → Slice 5 (link) → existing build phase (BuildModules, which already takes a lockfile + virtual-store dir; for hoisted, rebuild needs to be retargeted to operate on hoisted_locations directories).
Populate Modules.hoisted_dependencies and Modules.hoisted_locations in build_modules_manifest.
Implement MISSING_HOISTED_LOCATIONS error for the rebuild path.
Slice 7 — Build phase under hoisted (BuildModules retargeting)
Hoisted's build phase runs over real directories from hoistedLocations, not virtual-store slots.
Scope:
Add a hoisted-mode entry into BuildModules (or a sibling type) that iterates hoistedLocations rather than virtual_store_dir/<slot>/node_modules/<pkg>.
extraBinPaths per-package is binDirsInAllParentDirs(pkgRoot, lockfileDir) — every ancestor node_modules/.bin up to lockfileDir, in lookup order. Port the helper.
Add --node-linker [isolated|hoisted|pnp] to install / add / update (probably the top-level CliArgs). Overrides Config.node_linker.
Slice 9 — Workspace-aware hoisting
Depends on workspace support landing in pacquet (separate roadmap item).
Scope:
Real-hoist tree includes every workspace importer as a child of the virtual root, named encodeURIComponent(importerId) with reference = workspace:<id>.
lockfileToHoistedDepGraph analog produces per-project hoist subtrees and a workspace-wide direct_deps_by_importer_id.
link:-direct deps go through symlink_direct_dependencies (already implemented) instead of the real-directory linker.
Workspace hoisted installs ignore --filter for the hoist step — globally consistent decisions only.
hoistWorkspacePackages (default true) plumbed.
Slice 10 — hoistingLimits and externalDependencies knobs
Programmatic-only upstream; pacquet may still want them exposed as pnpm-workspace.yaml settings for parity.
Tail end. Mirror upstream's error codes via pacquet-diagnostics.
Scope:
ERR_PNPM_BUNDLED_DEPENDENCIES_WITHOUT_HOISTED, ERR_PNPM_MISSING_HOISTED_LOCATIONS, ERR_PNPM_LOCKFILE_MISSING_DEPENDENCY. Each at its appropriate validate-or-resolve site.
Tests to port
From plans/TEST_PORTING.md "Support nodeLinker=hoisted":
Primary:
installing/deps-installer/test/hoistedNodeLinker/install.ts:16installing with hoisted node-linker
:45installing with hoisted node-linker and no lockfile
:61overwriting (is-positive@3.0.0 with is-positive@latest)
:83overwriting existing files in node_modules
:97preserve subdeps on update
:119adding a new dependency to one of the workspace projects (depends on Slice 9)
:172installing the same package with alias and no alias
:187run pre/postinstall scripts. bin files should be linked in a hoisted node_modules
:210running install scripts in a workspace that has no root project (depends on Slice 9)
:229hoistingLimits should prevent packages to be hoisted (depends on Slice 10)
:247externalDependencies should prevent package from being hoisted to the root (depends on Slice 10)
:264linking bins of local projects when node-linker is set to hoisted (depends on Slice 9)
:314peerDependencies should be installed when autoInstallPeers is set to true and nodeLinker is set to hoisted
:329installing with hoisted node-linker a package that is a peer dependency of itself
installing/deps-installer/test/install/multipleImporters.ts:87 (depends on Slice 9)
Support
nodeLinker: 'hoisted'— umbrellaTracks everything pacquet needs to do to support pnpm's hoisted node-linker mode end to end. Today the value deserializes and round-trips into
.modules.yaml, but no install code branches on it —pacquet installwithnodeLinker: hoistedsilently produces an isolated layout and a.modules.yamlthat claims it is hoisted.This is an umbrella, not a single PR. See Workstream for the independently shippable slices.
Upstream references pin
pnpm/pnpmmainat94240bc046.Goals
pacquet installwithnodeLinker: hoistedproduces the samenode_modules/shape as pnpm — real directories at the project root, the hoist algorithm matching pnpm's choice of which version wins each top-level slot, conflicts placed under<parent>/node_modules/<conflict>..modules.yamlround-trip parity.hoistedDependenciesandhoistedLocationswritten faithfully so subsequent installs honor the same plan and rebuild knows where each depPath lives.rimraforphans, in the same order pnpm does.Non-goals
nodeLinker: 'pnp'is out of scope.pnpm deployunder hoisted. Quietly disables lockfile use upstream; pacquet has nodeploycommand yet.pacquet install --frozen-lockfile#432.injectionTargetsByDepPathis populated but the re-mirror step is a separate code path.pnpm install --filtersubset under hoisted. Hoisting decisions must be globally consistent, so partial installs effectively don't exist under this linker. Worth noting but not implementing.Upstream surface
Every claim cites a permalink at
94240bc046.1. Conceptual model
Hoisted produces real directories at
node_modules/<pkg>/, not symlinks. Conflicts nest under<parent>/node_modules/<conflict>. Nonode_modules/.pnpm/virtual store; the only on-disk persistence besides the tree itself is.modules.yaml. Two data structures:HoistedDependencies—Record<DepPath | ProjectId, Record<alias, 'public' | 'private'>>. The plan: per depPath, the aliases under which it appears at the top level. https://github.com/pnpm/pnpm/blob/94240bc046/core/types/src/misc.ts#L57hoistedLocations—Record<DepPath, string[]>of lockfile-relative directory paths. Per-depPath list because hoisted graph nodes are keyed by directory, not depPath (the same package appears in N graph nodes if it sits in N parents). https://github.com/pnpm/pnpm/blob/94240bc046/installing/deps-restorer/src/lockfileToHoistedDepGraph.ts#L2952. Pipeline entry points (every branch on
nodeLinker === 'hoisted')_installInContextwithlockfileOnly: true(resolution only), then re-entersheadlessInstallwith the in-memorynewLockfile. So everything that matters happens in the headless path. https://github.com/pnpm/pnpm/blob/94240bc046/installing/deps-installer/src/install/index.ts#L1767lockfileToHoistedDepGraphvslockfileToDepGraph. https://github.com/pnpm/pnpm/blob/94240bc046/installing/deps-restorer/src/index.ts#L369linkHoistedModules(andsymlinkDirectDependenciesforlink:entries only) vs the isolatedlinkAllModules/linkAllPkgs. https://github.com/pnpm/pnpm/blob/94240bc046/installing/deps-restorer/src/index.ts#L411linkBinsOfImporter; isolated useslinkBinsOfPackages. https://github.com/pnpm/pnpm/blob/94240bc046/installing/deps-restorer/src/index.ts#L614pruneis skipped under hoisted; cleanup happens insidelinkHoistedModules. https://github.com/pnpm/pnpm/blob/94240bc046/installing/deps-restorer/src/index.ts#L272pkgRootscome fromctx.modulesFile.hoistedLocationsrather than the virtual store. ThrowsMISSING_HOISTED_LOCATIONSif the dict is missing. https://github.com/pnpm/pnpm/blob/94240bc046/building/after-install/src/index.ts#L340node_modulesunder hoisted. https://github.com/pnpm/pnpm/blob/94240bc046/deps/status/src/checkDepsStatus.ts#L1963.
lockfileToHoistedDepGraphinstalling/deps-restorer/src/lockfileToHoistedDepGraph.ts. Takes the wanted lockfile (+ optional current); returns{graph, prevGraph, hierarchy, directDependenciesByImporterId, hoistedLocations, injectionTargetsByDepPath, symlinkedDirectDependenciesByImporterId}.prevGraphis whatlinkHoistedModulesdiffs against to find orphans.Internally calls
hoist(lockfile, …)(real-hoist), thenfetchDepswalks theHoisterResulttree:dir = path.join(modules, dep.name)per node — root deps go under<lockfileDir>/node_modules/<alias>, nested under<parent>/node_modules/<alias>. https://github.com/pnpm/pnpm/blob/94240bc046/installing/deps-restorer/src/lockfileToHoistedDepGraph.ts#L221hoistedLocations[depPath].push(depLocation). https://github.com/pnpm/pnpm/blob/94240bc046/installing/deps-restorer/src/lockfileToHoistedDepGraph.ts#L295injectionTargetsByDepPathfordirectoryresolutions. https://github.com/pnpm/pnpm/blob/94240bc046/installing/deps-restorer/src/lockfileToHoistedDepGraph.ts#L286packageIsInstallablereturns false (cpu/os/libc/engines) — adds toopts.skipped. SameSkippedset the isolated path uses.storeController.fetchPackagewithskipFetch: truewhencurrentHoistedLocationssays the package already exists on disk and itspackage.jsonis present. https://github.com/pnpm/pnpm/blob/94240bc046/installing/deps-restorer/src/lockfileToHoistedDepGraph.ts#L2334. Hoist algorithm (
real-hoist)installing/linking/real-hoist/src/index.tswraps@yarnpkg/nm/hoist:HoisterTreerooted atname: '.', children = root importer'sdependencies + devDependencies + optionalDependencies. https://github.com/pnpm/pnpm/blob/94240bc046/installing/linking/real-hoist/src/index.ts#L38-L50externalDependenciesenter aslink:placeholders that occupy name slots so nothing else hoists under those aliases; they are stripped from the result. https://github.com/pnpm/pnpm/blob/94240bc046/installing/linking/real-hoist/src/index.ts#L42-L75name = encodeURIComponent(importerId)andreference = workspace:<importerId>. The hoister sees the whole workspace as one tree. https://github.com/pnpm/pnpm/blob/94240bc046/installing/linking/real-hoist/src/index.ts#L51-L66toTreelooks uplockfile.packages[depPath]and throwsLOCKFILE_MISSING_DEPENDENCYif absent. https://github.com/pnpm/pnpm/blob/94240bc046/installing/linking/real-hoist/src/index.ts#L109-L111peerNamesfor each package ispeerDependencies + transitivePeerDependencies— unlessautoInstallPeersis on, in which case it'snew Set([])and the hoister moves the package freely. https://github.com/pnpm/pnpm/blob/94240bc046/installing/linking/real-hoist/src/index.ts#L124-L129The
@yarnpkg/nmhoister is not pure pnpm code. For pacquet either (a) port@yarnpkg/nm/hoistto Rust, or (b) reimplement an equivalent name-conflict-aware tree-hoister with the same observable behavior (parent wins; respectpeerNames; respecthoistingLimits). Either way, the interface into the hoister is what pacquet builds — the tree shape, the workspace-as-children trick, the peer-names knob, the external-dependencies trick.5.
.modules.yamlschema (hoisted-linker fields)installing/modules-yaml/src/index.ts:23-48. https://github.com/pnpm/pnpm/blob/94240bc046/installing/modules-yaml/src/index.ts#L23-L48Fields that this feature reads and writes:
hoistedDependencies: HoistedDependencies— the plan from real-hoist.hoistedLocations: Record<string, string[]>— hoisted-linker only. Required by rebuild (MISSING_HOISTED_LOCATIONS) and by the skip-fetch optimization inlockfileToHoistedDepGraph.nodeLinker: 'hoisted'— the persisted linker selection.included: IncludedDependencies— validated against the current install;INCLUDED_DEPS_CONFLICTon mismatch.Writer (canonical, both modes): https://github.com/pnpm/pnpm/blob/94240bc046/installing/deps-restorer/src/index.ts#L660-L682
6. Bin linking under hoisted
linkHoistedModulesrunslinkBins(modulesDir, binsDir, {allowExoticManifests: true})once pernode_modulesit visits (parent of every hoist location). https://github.com/pnpm/pnpm/blob/94240bc046/installing/deps-restorer/src/linkHoistedModules.ts#L154-L160linkBinsOfImporter. https://github.com/pnpm/pnpm/blob/94240bc046/installing/deps-restorer/src/index.ts#L614-L651extraBinPathsare gathered from all parent dirs viabinDirsInAllParentDirs(pkgRoot, lockfileDir)— mimics npm's resolution up the ancestor chain. https://github.com/pnpm/pnpm/blob/94240bc046/building/after-install/src/index.ts#L3577.
hoistingLimitsandexternalDependenciesBoth knobs are inputs to
real-hoist— they exist specifically for the hoisted linker.hoistingLimits: Map<string, Set<string>>— outer key is importer slot (e.g.'.@'), value is aliases that may NOT be hoisted past this slot. Programmatic only — no CLI flag. https://github.com/pnpm/pnpm/blob/94240bc046/installing/linking/real-hoist/src/index.ts#L10externalDependencies: Set<string>— name slots reserved at the root for an external linker (Bit CLI). Programmatic only. https://github.com/pnpm/pnpm/blob/94240bc046/installing/linking/real-hoist/src/index.ts#L188. Workspace semantics
One shared hoisted tree at the workspace root, plus per-project
node_modulesfor whatever couldn't hoist up:lockfileToHoistedDepGraphwalks each non-root importer's hoist subtree under<projectDir>/node_modulesand recordsdirectDependenciesByImporterId+symlinkedDirectDependenciesByImporterId(forlink:deps). https://github.com/pnpm/pnpm/blob/94240bc046/installing/deps-restorer/src/lockfileToHoistedDepGraph.ts#L113-L128node_modules/.bin. Test: https://github.com/pnpm/pnpm/blob/94240bc046/installing/deps-installer/test/hoistedNodeLinker/install.ts#L264hoistWorkspacePackages(defaulttrue) — under hoisted, workspace projects are always included in the hoist tree by virtue of being children of the virtual root.9. Peer-dep interaction with
autoInstallPeersautoInstallPeers: truezeroes outpeerNamesfor the hoister, letting it move packages past parents that supply their peers. https://github.com/pnpm/pnpm/blob/94240bc046/installing/linking/real-hoist/src/index.ts#L124-L129Tests:
hoistedNodeLinker/install.ts:314(react-dom with autoInstallPeers expectsnode_modules/reactat top) https://github.com/pnpm/pnpm/blob/94240bc046/installing/deps-installer/test/hoistedNodeLinker/install.ts#L314 and:329(peer-of-self regression) https://github.com/pnpm/pnpm/blob/94240bc046/installing/deps-installer/test/hoistedNodeLinker/install.ts#L32910. Purge / reinstall semantics
Direct-on-disk packages can drift; hoisted does its own cleanup:
.binconsistent:linkHoistedModulesdiffsprevGraph(current lockfile's plan) againstgraph(wanted plan) andrimrafs the surplus. https://github.com/pnpm/pnpm/blob/94240bc046/installing/deps-restorer/src/linkHoistedModules.ts#L43-L54importPackagecall passesforce: trueandkeepModulesDir: true— overwrites existing directories every link pass. https://github.com/pnpm/pnpm/blob/94240bc046/installing/deps-restorer/src/linkHoistedModules.ts#L134tryRemoveDirswallows EPERM/EBUSY (best-effort cleanup). https://github.com/pnpm/pnpm/blob/94240bc046/installing/deps-restorer/src/linkHoistedModules.ts#L7211. Modules-cache / lockfile invalidation
The lockfile-level outdated-settings detector explicitly excludes
nodeLinkerandhoistingLimits— switching to hoisted does NOT invalidate the lockfile. Pacquet's outdated detector must match. https://github.com/pnpm/pnpm/blob/94240bc046/lockfile/settings-checker/src/getOutdatedLockfileSetting.ts12. Forbidden combinations / errors specific to hoisted
BUNDLED_DEPENDENCIES_WITHOUT_HOISTED—bundledDependenciesonly works withnodeLinker: hoisted. https://github.com/pnpm/pnpm/blob/94240bc046/releasing/commands/src/publish/pack.ts#L340MISSING_HOISTED_LOCATIONS— rebuild can't find a depPath in.modules.yaml.hoistedLocations. https://github.com/pnpm/pnpm/blob/94240bc046/building/after-install/src/index.ts#L345LOCKFILE_MISSING_DEPENDENCY— hoister given a depPath with nopackages[depPath]. https://github.com/pnpm/pnpm/blob/94240bc046/installing/linking/real-hoist/src/index.ts#L111Pacquet's current state
Config.node_linkerdeserializecrates/config/src/lib.rs:28-41(enum),:164(field),crates/config/src/workspace_yaml.rs:53+:207-208(plumb)..modules.yamlround-trip ofnodeLinkercrates/package-manager/src/install.rs:242-247(map_node_linker),crates/modules-yaml/src/lib.rs:167(field). Round-trip tests atcrates/modules-yaml/tests/index.rs:25,82,86,.....modules.yaml.hoistedDependenciesfieldcrates/modules-yaml/src/lib.rs:155;HoistKindenum at:303. LegacyhoistedAliasesmigrator at:461-482. Always written empty (build_modules_manifestatcrates/package-manager/src/install.rs:259-278leaves it..Default::default(), with the doc-comment at:254-258calling out the gap)..modules.yaml.hoistedLocationsfieldhoisted_locationsonModules. (grep -rn "hoisted_locations" crates/returns nothing in the struct schema.) This field is required for rebuild under hoisted.node_linkergrep -rn "NodeLinker::Hoisted" crates/outside the enum + serde glue + themap_node_linkerwriter returns zero hits. Hoisted installs silently produce the isolated layout.lockfileToHoistedDepGraphanalogDependenciesGraphbuilder.crates/package-manager/src/deps_graph.rsis depPath-keyed and used only for cache hashing.@yarnpkg/nm/hoistor equivalent. Nohoisting_limitsorexternal_dependenciesplumbing.linkHoistedModulesanalogcreate_virtual_store.rs+symlink_direct_dependencies.rs). No real-directory import path that overwrites withforce: true.node_moduleslink_bins.rs:30-65(link_direct_dep_bins) writes shims to<modules_dir>/.binfrom direct-dep symlinks. The hoisted path needs to do this from real directories and per-parent<dir>/node_modules/.binper hoist location.--node-linkerCLI flagnode-linker/node_linkerreferences incrates/cli/src/cli_args/. Setting requirespnpm-workspace.yamlor.npmrc.crates/package-manager/src/install.rs:95-99documents the absence. Workspaces are a prerequisite for hoisted full-parity, but a single-importer hoisted install is shippable first.bundledDependenciesextractionBUNDLED_DEPENDENCIES_WITHOUT_HOISTEDis an upstream error gating this feature on hoisted.In one sentence: pacquet has the deserialize-and-persist surface for hoisted; everything that produces the on-disk layout is missing.
Workstream
Slices are roughly ordered. Earlier slices unblock later ones.
Slice 1 — Real-directory linking primitive
Foundational. Hoisted's import step is "copy/hardlink the package's files into
<parent>/node_modules/<alias>/, possibly overwriting an existing directory". Pacquet's existing primitives all symlink. This slice ports the equivalent ofstoreController.importPackagewithkeepModulesDir: trueandforce: true.Scope:
crates/package-manager/(or a helper incrates/store-dir/since it consumes the CAS) that takes a CAS file index + destination path and populates the destination as real files (matching pacquet's existing import method preference: hardlink, then clonefile, then copy).node_modules/subdirectory is preserved (keepModulesDir: true) so nested deps don't get clobbered between the rimraf-orphans pass and the insert pass.Slice 2 —
.modules.yaml.hoistedLocationsfieldRequired for rebuild and for the "is the package already on disk" skip-fetch optimization.
Scope:
hoisted_locations: Option<BTreeMap<DepPath, Vec<String>>>toModules(crates/modules-yaml/src/lib.rs)..modules.yaml; writer serializes when non-empty.crates/package-manager/src/install.rs:254-258.Slice 3 — Hoist algorithm port (single-importer)
The load-bearing slice.
Scope:
@yarnpkg/nm/hoistin Rust, or port the upstream wrapper around it. Public surface matches upstream'shoist(lockfile, opts): input = the lockfile + root importer's direct deps +hoistingLimits+externalDependencies+ per-packagepeerNames. Output = a tree of(alias, depPath, children)rooted at the project.Map<ImporterId, …>once workspaces land.hoistingLimits/externalDependenciesknobs — accept empty inputs for now; wire them later when a real consumer appears.installing/linking/real-hoist/test/cases.Slice 4 —
lockfileToHoistedDepGraphanalogWalks the hoist result and produces the directory-keyed graph that the link step consumes.
Scope:
walk_hoist_result(hoist_root, lockfile_dir) -> { graph, hoisted_locations, direct_deps_by_importer, …}.graphis keyed by absolute directory path; each node carriesdepPath,alias,children, the package'spackage.json, etc.hoisted_locationsmap for Slice 2 / 5.Skippedset instead.hoistedDependencies/hoistedLocationsto produce aprev_graphfor Slice 6's orphan diff.Slice 5 —
link_hoisted_modules(the install-time linker)Produces the on-disk tree from Slice 4's graph using Slice 1's primitive.
Scope:
graphandprev_graph; rimraf surplus directories first (orphan removal), then import packages second. MatchtryRemoveDir's EPERM/EBUSY tolerance.force=true,keep_modules_dir=true.node_modulesbin link: walk children, link each one's bins into<node_modules>/.bin. Per the upstream pass, this is one call per parent directory.currentHoistedLocations[depPath]includes the target directory and<dir>/package.jsonexists, do not refetch from CAS.Slice 6 — Pipeline branch and
Install::runwiringPlug Slices 3–5 into the install pipeline behind a
node_linker == Hoistedbranch.Scope:
Install::run(install.rs) andInstallFrozenLockfile::run(install_frozen_lockfile.rs), branch onconfig.node_linker:NodeLinker::Isolated→ existing path.NodeLinker::Hoisted→ Slice 3 (hoist) → Slice 4 (walk) → Slice 5 (link) → existing build phase (BuildModules, which already takes a lockfile + virtual-store dir; for hoisted, rebuild needs to be retargeted to operate onhoisted_locationsdirectories).Modules.hoisted_dependenciesandModules.hoisted_locationsinbuild_modules_manifest.MISSING_HOISTED_LOCATIONSerror for the rebuild path.Slice 7 — Build phase under hoisted (
BuildModulesretargeting)Hoisted's build phase runs over real directories from
hoistedLocations, not virtual-store slots.Scope:
BuildModules(or a sibling type) that iterateshoistedLocationsrather thanvirtual_store_dir/<slot>/node_modules/<pkg>.extraBinPathsper-package isbinDirsInAllParentDirs(pkgRoot, lockfileDir)— every ancestornode_modules/.binup tolockfileDir, in lookup order. Port the helper.Slice 8 —
--node-linkerCLI flagTiny.
Scope:
--node-linker [isolated|hoisted|pnp]toinstall/add/update(probably the top-levelCliArgs). OverridesConfig.node_linker.Slice 9 — Workspace-aware hoisting
Depends on workspace support landing in pacquet (separate roadmap item).
Scope:
encodeURIComponent(importerId)withreference = workspace:<id>.lockfileToHoistedDepGraphanalog produces per-project hoist subtrees and a workspace-widedirect_deps_by_importer_id.link:-direct deps go throughsymlink_direct_dependencies(already implemented) instead of the real-directory linker.--filterfor the hoist step — globally consistent decisions only.hoistWorkspacePackages(default true) plumbed.Slice 10 —
hoistingLimitsandexternalDependenciesknobsProgrammatic-only upstream; pacquet may still want them exposed as
pnpm-workspace.yamlsettings for parity.Scope:
Config.hoisting_limits: BTreeMap<String, BTreeSet<String>>.Config.external_dependencies: BTreeSet<String>.Slice 11 — Forbidden-combinations errors
Tail end. Mirror upstream's error codes via
pacquet-diagnostics.Scope:
ERR_PNPM_BUNDLED_DEPENDENCIES_WITHOUT_HOISTED,ERR_PNPM_MISSING_HOISTED_LOCATIONS,ERR_PNPM_LOCKFILE_MISSING_DEPENDENCY. Each at its appropriate validate-or-resolve site.Tests to port
From
plans/TEST_PORTING.md"SupportnodeLinker=hoisted":Primary:
installing/deps-installer/test/hoistedNodeLinker/install.ts:16installing with hoisted node-linker:45installing with hoisted node-linker and no lockfile:61overwriting (is-positive@3.0.0 with is-positive@latest):83overwriting existing files in node_modules:97preserve subdeps on update:119adding a new dependency to one of the workspace projects(depends on Slice 9):172installing the same package with alias and no alias:187run pre/postinstall scripts. bin files should be linked in a hoisted node_modules:210running install scripts in a workspace that has no root project(depends on Slice 9):229hoistingLimits should prevent packages to be hoisted(depends on Slice 10):247externalDependencies should prevent package from being hoisted to the root(depends on Slice 10):264linking bins of local projects when node-linker is set to hoisted(depends on Slice 9):314peerDependencies should be installed when autoInstallPeers is set to true and nodeLinker is set to hoisted:329installing with hoisted node-linker a package that is a peer dependency of itselfinstalling/deps-installer/test/install/multipleImporters.ts:87(depends on Slice 9)Frozen/headless cross-coverage:
optionalDependencies.ts:594includes hoisted frozen install (depends on Proper optionalDependencies support — umbrella #434)patch.ts:24,:120,:297,:386(depends on patching parity — already present)lifecycleScripts.ts:579,:686deps-restorer/test/index.ts:859,:873Cross-references
optionalDependenciessupport. The installability check from Slice 1 of Proper optionalDependencies support — umbrella #434 is consumed by Slice 4 here.pacquet install --frozen-lockfile#432 — Global virtual store. Interacts with hoisted:privateHoistedModulesDirmoves under GVS. Track follow-ups under Add global virtual store support topacquet install --frozen-lockfile#432.--frozen-lockfile: read+writenode_modules/.pnpm/lock.yamland skip unchanged snapshots #433 — Partial install. Hoisted is incompatible with partial install (hoisting must be globally consistent); record this when Partial install with--frozen-lockfile: read+writenode_modules/.pnpm/lock.yamland skip unchanged snapshots #433 lands.Written by an agent (Claude Code, claude-opus-4-7).