Skip to content
This repository was archived by the owner on May 14, 2026. It is now read-only.
This repository was archived by the owner on May 14, 2026. It is now read-only.

Add hoisting support: hoistPattern and publicHoistPattern #435

@zkochan

Description

@zkochan

Background

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.

What upstream does

References at pnpm v11 94240bc046:

  • Algorithm. installing/linking/hoist/src/index.ts:
    • 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:
    • * wildcard, ? not supported.
    • !-prefix ignore patterns.
    • First-match-wins with the three branches at config/matcher/src/index.ts:43-58 (hasIgnore/hasInclude combinations).
    • Empty pattern list never matches.
  • Port the test cases from config/matcher/test/index.ts.

B. Hoist algorithm

  • New module under crates/package-managerhoist.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.
  • Output the HoistedDependencies map shape pacquet's crates/modules-yaml already expects (BTreeMap<DepPath, BTreeMap<alias, HoistKind>>).

C. Symlinks and bins

  • 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-122getHoistedDependencies 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.
  • Swallow BINARIES_CONFLICT warnings (upstream does at hoist/src/index.ts:145-149).

D. Wiring + persistence

  • 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.
  • If the new patterns differ from the persisted ones in an existing .modules.yaml, treat that as a "needs full re-hoist" signal — upstream forces re-import in deps-restorer/src/index.ts:357. Out of scope for this issue if partial install (Partial install with --frozen-lockfile: read+write node_modules/.pnpm/lock.yaml and skip unchanged snapshots #433) hasn't landed; capture as a follow-up checkbox.

E. Tests

  • 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.
  • shamefullyHoist: true (legacy) → publicHoistPattern: ['*'] behavior.
  • .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).
  • Port the relevant cases from installing/deps-installer/test/install/hoist.ts per plans/TEST_PORTING.md.

Interactions / out of scope

Related


Written by an agent (Claude Code, claude-opus-4-7).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions