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

feat: add hoisting support (hoistPattern + publicHoistPattern) (#435)#445

Merged
zkochan merged 10 commits into
mainfrom
feat/435
May 13, 2026
Merged

feat: add hoisting support (hoistPattern + publicHoistPattern) (#435)#445
zkochan merged 10 commits into
mainfrom
feat/435

Conversation

@zkochan
Copy link
Copy Markdown
Member

@zkochan zkochan commented May 13, 2026

Closes #435.

Summary

  • Ports @pnpm/config.matcher (* glob, ! negation, first-match-wins) into a new pacquet_config::matcher module — no regex dependency.
  • Ports getHoistedDependencies and symlinkHoistedDependencies from upstream installing/linking/hoist/src/index.ts into a new pacquet_package_manager::hoist module. BFS by depth + lex on nodeId, public wins ties, first-seen wins per alias, root importer's direct-dep names seed the "already hoisted" set.
  • Wires the hoist pass into InstallFrozenLockfile::run between the per-slot bin link and the importing_done emit. Result is threaded through Install::run into .modules.yaml's hoistedDependencies.
  • Promotes Config.hoist_pattern and Config.public_hoist_pattern to Option<Vec<String>> so the install-time guard mirrors upstream's != null check (issue §D).

Upstream pinned at pnpm v11 94240bc046.

Tests

  • Matcher (crates/config/src/matcher.rs) — 6 tests, including direct ports of upstream's matcher() and createMatcherWithIndex() test cases plus regex-special-char-literal coverage.
  • Hoist algorithm (crates/package-manager/src/hoist/tests.rs) — 12 unit tests covering empty graph, default * pattern, public hoist, public-wins-ties, negation, first-seen-wins, root direct-dep blocking, skipped-snapshot exclusion, bin-set collection, and graph-builder + direct-deps-builder helpers.
  • End-to-end (crates/cli/tests/hoist.rs) — 9 integration tests using pnpm to generate the lockfile and pacquet to install with --frozen-lockfile. Cover default private hoist, publicHoistPattern: '*', both-empty, shamefullyHoist legacy, scoped-pattern filter, !-negation, .modules.yaml round-trip, and bin linking on both private and public sides.
  • Known failures (17 stubs) — Ports the remainder of installing/deps-installer/test/install/hoist.ts into known_failures modules via pacquet_testing_utils::allow_known_failure!. Each stub names the upstream URL line + the feature blocking it (workspace install Add workspace support to pacquet install --frozen-lockfile #431, partial install Partial install with --frozen-lockfile: read+write node_modules/.pnpm/lock.yaml and skip unchanged snapshots #433, skipped optional deps, direct-dep bin precedence, extendNodePath).

plans/TEST_PORTING.md updated to mark every ported hoist.ts entry with the corresponding pacquet test name.

Out of scope (per the issue)

Test plan

  • cargo test -p pacquet-config matcher — 6/6 pass
  • cargo test -p pacquet-package-manager hoist::tests — 12/12 pass
  • cargo nextest run -p pacquet-cli --test hoist — 35/35 pass (9 real + 26 known_failure stubs)
  • just ready — 670/670 tests pass
  • taplo format --check — clean
  • just dylint — clean
  • Manually broke Matcher::matches (always-true) — both matcher unit tests and the both_patterns_empty_produces_no_hoist_symlinks integration test caught it; reverted.

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

Summary by CodeRabbit

  • New Features

    • Tri-state hoisting config for private/public patterns (disabled, explicit-empty, or normal matching)
    • Hoisted dependencies are now saved in module manifests and used to create installer hoist symlinks and bin links
    • New glob-style matcher and hoisting algorithm to control public vs private hoisting behavior
  • Tests

    • Extensive unit tests and Unix E2E coverage for many hoisting scenarios; numerous unimplemented cases tracked as known failures
  • Documentation

    • Updated test-porting notes to reflect hoist coverage changes

Review Change Stack

Copilot AI review requested due to automatic review settings May 13, 2026 10:46
@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 13, 2026

Micro-Benchmark Results

Linux

group                          main                                   pr
-----                          ----                                   --
tarball/download_dependency    1.02     16.5±0.69ms   263.3 KB/sec    1.00     16.1±0.07ms   268.7 KB/sec

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds pnpm-style dependency hoisting to pacquet installs, including pattern matching (hoistPattern / publicHoistPattern) and persistence of hoistedDependencies into .modules.yaml, to improve compatibility with packages that rely on phantom dependencies.

Changes:

  • Introduces a new pacquet_config::matcher module implementing pnpm’s glob/negation “first match wins” semantics (no regex dependency).
  • Ports pnpm’s hoist graph walk + symlink creation into pacquet_package_manager::hoist and wires it into the frozen-lockfile install pipeline; threads the resulting map into .modules.yaml.
  • Adds unit + integration tests for matcher and hoist behavior; updates plans/TEST_PORTING.md to mark ported/stubbed upstream cases.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
plans/TEST_PORTING.md Marks upstream hoist tests as ported or stubbed with rationale.
crates/package-manager/src/lib.rs Adds and re-exports the new hoist module.
crates/package-manager/src/install.rs Threads HoistedDependencies into .modules.yaml generation.
crates/package-manager/src/install_without_lockfile.rs Returns an empty HoistedDependencies map for symmetry with frozen-lockfile installs.
crates/package-manager/src/install_package_from_registry/tests.rs Updates tests for Option<Vec<String>> hoist patterns.
crates/package-manager/src/install_frozen_lockfile.rs Runs hoist pass after linking and persists resulting hoistedDependencies.
crates/package-manager/src/hoist.rs Implements hoist graph build, BFS selection, symlink creation, and private-hoist bin linking.
crates/package-manager/src/hoist/tests.rs Adds algorithm unit tests (patterns, precedence, skipping, bins set, helpers).
crates/config/src/workspace_yaml.rs Applies workspace yaml overrides for hoist patterns (now Option<Vec<String>>).
crates/config/src/matcher.rs Adds pnpm matcher port with tests.
crates/config/src/lib.rs Promotes hoist patterns to Option<Vec<String>> and exports matcher module.
crates/cli/tests/hoist.rs Adds end-to-end hoist integration tests + known-failure stubs.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread crates/package-manager/src/install_frozen_lockfile.rs Outdated
Comment thread crates/config/src/workspace_yaml.rs Outdated
Comment thread crates/package-manager/src/hoist.rs Outdated
Comment thread crates/cli/tests/hoist.rs Outdated
@codecov
Copy link
Copy Markdown

codecov Bot commented May 13, 2026

Codecov Report

❌ Patch coverage is 97.16024% with 14 lines in your changes missing coverage. Please review.
✅ Project coverage is 88.69%. Comparing base (b13b818) to head (63ba38c).

Files with missing lines Patch % Lines
crates/package-manager/src/hoist.rs 94.66% 11 Missing ⚠️
crates/config/src/matcher.rs 99.06% 2 Missing ⚠️
...tes/package-manager/src/install_frozen_lockfile.rs 97.67% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #445      +/-   ##
==========================================
+ Coverage   87.32%   88.69%   +1.37%     
==========================================
  Files         114      116       +2     
  Lines        9435     9917     +482     
==========================================
+ Hits         8239     8796     +557     
+ Misses       1196     1121      -75     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 13, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds tri-state hoist configuration and workspace YAML handling, a pnpm-compatible glob matcher with ignore semantics, a BFS-based hoist decision pass with public/private precedence, symlink/bin-link wiring, install integration persisting hoistedDependencies into .modules.yaml, plus unit and Unix-only E2E tests.

Changes

Hoisting Implementation

Layer / File(s) Summary
Config & Pattern Matcher Infrastructure
crates/config/src/lib.rs, crates/config/src/matcher.rs, crates/config/src/workspace_yaml.rs
Config hoist fields become tri-state (outer Option for presence, inner Option<Vec<String>> for explicit null/list), new matcher module (create_matcher, create_matcher_with_index, Matcher, MatcherWithIndex) implements pnpm-style * globs and ! negation with index semantics; WorkspaceSettings uses Option<Option<Vec<String>>> with a serde double-option helper and explicit apply logic.
Hoist Data Model & Graph Builders
crates/package-manager/src/hoist.rs
Adds HoistedDependencies, HoistGraphNode, build_hoist_graph, and build_direct_deps_by_importer to construct the per-snapshot hoist graph and importer-scoped direct-deps maps.
Hoist BFS Decision Pass
crates/package-manager/src/hoist.rs
Adds get_hoisted_dependencies performing deterministic BFS seeded from importer direct deps, public-over-private matcher precedence, case-insensitive alias reservation seeded from root direct deps, per-node and persisted hoist maps, and collection of private vs public bin candidates.
Symlink Creation & Helpers
crates/package-manager/src/hoist.rs
Implements symlink_hoisted_dependencies to compute targets/destinations, pre-create hoisted-module roots and scoped parents, create symlinks in parallel (swallowing AlreadyExists), and helper name_to_dir.
Install Pipeline Integration
crates/package-manager/src/install.rs, crates/package-manager/src/install_frozen_lockfile.rs, crates/package-manager/src/install_without_lockfile.rs, crates/package-manager/src/lib.rs
Install paths now return HoistedDependencies. Frozen-lockfile path materializes dependency_groups, guards hoist on pattern presence, builds graph and matchers, runs hoist, symlinks private/public hoists, links bins, and returns the hoist map; without-lockfile returns an empty map. InstallFrozenLockfileError gains hoist-specific variants and Install::run threads hoistedDependencies into .modules.yaml via build_modules_manifest.

End-to-End Tests and Documentation

Layer / File(s) Summary
Hoist Unit Tests
crates/package-manager/src/hoist/tests.rs
Unit tests validate hoist algorithm behaviors and helpers: empty-graph short-circuit, private/public hoisting, tie-breaking (public wins), negation, first-seen wins, direct-dep blocking, skipped snapshots, has_bin collection rules, direct-deps-by-importer, and graph building.
End-to-End Integration Tests
crates/cli/tests/hoist.rs
Unix-only E2E tests generate mocked pnpm-lock.yaml, run pacquet install --frozen-lockfile, and assert private vs public hoist symlinks, bin shim locations, and .modules.yaml hoistedDependencies across configurations; includes known_failures module gating upstream cases not implemented.
Docs & Workspace YAML Tests
plans/TEST_PORTING.md, crates/config/src/workspace_yaml/tests.rs
TEST_PORTING updated to mark hoist tests ported and map upstream cases to Rust tests or known_failures; adds tri-state workspace YAML round-trip unit test.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

Possibly related PRs

  • pnpm/pacquet#442: Overlapping modifications to the frozen-lockfile install pipeline; review for integration conflicts.

Suggested reviewers

  • Copilot

"I’m a rabbit who hops through globs and trees,
Symlinks snug and bins with ease,
Public roots and private stores,
BFS finds the hoisted scores,
Hooray — hoists tied up with glee! 🐰"

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The PR title 'feat: add hoisting support (hoistPattern + publicHoistPattern)' directly and clearly describes the main feature addition, which is fully reflected in the changeset across multiple modules implementing hoist functionality.
Description check ✅ Passed The PR description comprehensively covers the changes with Summary, Linked issue reference, Upstream reference, Tests, Out of scope sections, and Test plan—all structured per the template requirements for a non-trivial feature port.
Linked Issues check ✅ Passed The PR fully addresses all coding requirements in #435: ports the matcher with * and ! support [crates/config/src/matcher.rs], implements hoist algorithm with BFS and first-seen-wins [crates/package-manager/src/hoist.rs], creates private/public symlinks, wires the pass into Install::run, persists to .modules.yaml, and adds comprehensive test coverage (unit + integration + known_failures).
Out of Scope Changes check ✅ Passed All changes are scope-aligned: matcher and hoist modules implement #435 requirements; config tri-state promotion is required for null-vs-empty guard logic; test modules validate implementation; workspace/GVS/partial-install features are explicitly deferred as documented.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/435

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/config/src/workspace_yaml.rs`:
- Around line 217-231: The YAML fields collapse missing vs explicit null; change
the YAML-mapped fields to preserve explicit null by making self.hoist_pattern
and self.public_hoist_pattern use a nested Option (e.g.
Option<Option<Vec<String>>> or use serde to preserve nulls), then update the
assignment logic in the block around
config.hoist_pattern/config.public_hoist_pattern to match the outer Option and
assign the inner Option directly (e.g. match self.hoist_pattern { Some(inner) =>
config.hoist_pattern = inner, None => {} }) so that an explicit `hoistPattern:
null` sets Config.* = None while a missing field leaves the default.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 9a93fa2d-5055-4d9e-a0f1-00f9f99e1d92

📥 Commits

Reviewing files that changed from the base of the PR and between d6ff1d5 and 30c84c9.

📒 Files selected for processing (12)
  • crates/cli/tests/hoist.rs
  • crates/config/src/lib.rs
  • crates/config/src/matcher.rs
  • crates/config/src/workspace_yaml.rs
  • crates/package-manager/src/hoist.rs
  • crates/package-manager/src/hoist/tests.rs
  • crates/package-manager/src/install.rs
  • crates/package-manager/src/install_frozen_lockfile.rs
  • crates/package-manager/src/install_package_from_registry/tests.rs
  • crates/package-manager/src/install_without_lockfile.rs
  • crates/package-manager/src/lib.rs
  • plans/TEST_PORTING.md
📜 Review details
🧰 Additional context used
📓 Path-based instructions (3)
**/*.rs

📄 CodeRabbit inference engine (AGENTS.md)

**/*.rs: Preserve existing method chains and pipe-trait chains; do not break them into intermediate let bindings unless there is a concrete justification such as a compilation failure, borrow checker rejection, meaningful performance improvement, or other technical necessity. Refactoring for style alone is not sufficient justification.
Choose owned vs. borrowed parameters to minimize copies; prefer borrowed types (&Path over &PathBuf, &str over &String) when it does not force extra copies.
Prefer Arc::clone(&x) and Rc::clone(&x) over x.clone() for reference-counted types to make the cost visible at the call site.
Do not use star imports inside module bodies. Write use super::{Foo, bar} instead of use super::*; for any glob whose target is a module you control. External-crate preludes (e.g., use rayon::prelude::*;) and root-of-module re-exports (e.g., pub use submodule::*; in lib.rs) are exceptions.
Follow Rust API Guidelines for naming, as documented in https://rust-lang.github.io/api-guidelines/naming.html.
Declare a newtype wrapper for any branded string type being ported from TypeScript pnpm. Do not collapse the brand into a plain String or &str; give the type its own struct so misuse is a type error.
When porting branded string types where upstream TypeScript always validates before construction, validate in the Rust port too. Construct the wrapper only via TryFrom<String> and/or FromStr; do not provide an infallible public constructor that takes an arbitrary string.
For branded string types where upstream TypeScript never validates (used purely for type-safety to prevent confusion between string slots), expose an infallible From<String> and From<&str> constructor in the Rust wrapper.
When upstream TypeScript occasionally constructs a branded type without validation (via bare as assertion), add a from_str_unchecked (or similarly named) constructor on the Rust side. Keep the validating constructor as well; `from_str_u...

Files:

  • crates/package-manager/src/lib.rs
  • crates/config/src/workspace_yaml.rs
  • crates/package-manager/src/install_without_lockfile.rs
  • crates/package-manager/src/install_package_from_registry/tests.rs
  • crates/config/src/lib.rs
  • crates/package-manager/src/install_frozen_lockfile.rs
  • crates/package-manager/src/install.rs
  • crates/package-manager/src/hoist/tests.rs
  • crates/cli/tests/hoist.rs
  • crates/config/src/matcher.rs
  • crates/package-manager/src/hoist.rs
**/*.md

📄 CodeRabbit inference engine (AGENTS.md)

When citing upstream pnpm code anywhere (code comments, doc comments, Markdown docs, PR descriptions, or commit messages), link to a specific commit SHA, not a branch name. Use the first 10 hex characters of the SHA. Branch links like github.com/<owner>/<repo>/blob/main/... are impermanent; permanent links pin the commit so the reference stays meaningful long after upstream changes. This rule applies to references to any GitHub repository, not only pnpm/pnpm.

Files:

  • plans/TEST_PORTING.md
**/tests/**/*.rs

📄 CodeRabbit inference engine (AGENTS.md)

**/tests/**/*.rs: When porting behavior from pnpm, port the relevant pnpm tests to Rust tests whenever they translate. Matching test coverage is the easiest way to prove behavioral parity.
Consult the test-porting plan in plans/TEST_PORTING.md before adding ported tests. Follow the conventions expected for ports: use known_failures modules, use pacquet_testing_utils::allow_known_failure! at the not-yet-implemented boundary, and temporarily break the subject under test to verify the ported test actually catches the regression. Update TEST_PORTING.md checkboxes as items land.
Follow the test-logging guidance in CODE_STYLE_GUIDE.md: log before non-assert_eq! assertions, use dbg! for complex structures, skip logging for simple scalar assert_eq! assertions.

Files:

  • crates/cli/tests/hoist.rs
🧠 Learnings (3)
📚 Learning: 2026-05-07T23:19:08.272Z
Learnt from: KSXGitHub
Repo: pnpm/pacquet PR: 401
File: tasks/integrated-benchmark/src/work_env.rs:343-344
Timestamp: 2026-05-07T23:19:08.272Z
Learning: When reviewing Rust code in pnpm/pacquet for deprecated API usage, do not automatically treat `serde_saphyr::to_string` as deprecated. In `serde-saphyr` v0.0.25, `serde_saphyr::to_string` has no `#[deprecated]` attribute (the `#[deprecated]` later in `serde-saphyr-0.0.25/src/lib.rs` applies to a different function). Only flag `serde_saphyr::to_string` as deprecated if the resolved dependency version’s source shows `#[deprecated]` on that specific function.

Applied to files:

  • crates/package-manager/src/lib.rs
  • crates/config/src/workspace_yaml.rs
  • crates/package-manager/src/install_without_lockfile.rs
  • crates/package-manager/src/install_package_from_registry/tests.rs
  • crates/config/src/lib.rs
  • crates/package-manager/src/install_frozen_lockfile.rs
  • crates/package-manager/src/install.rs
  • crates/package-manager/src/hoist/tests.rs
  • crates/cli/tests/hoist.rs
  • crates/config/src/matcher.rs
  • crates/package-manager/src/hoist.rs
📚 Learning: 2026-05-01T10:01:33.766Z
Learnt from: zkochan
Repo: pnpm/pacquet PR: 349
File: crates/reporter/src/tests.rs:121-121
Timestamp: 2026-05-01T10:01:33.766Z
Learning: In Rust test code, follow the repo’s CODE_STYLE_GUIDE test-logging rule: add logging (e.g., `eprintln!`/`eprintln!(...)`) so that useful diagnostic values are printed when a test fails, unless the assertion is `assert_eq!` (where the differing values are already included). Concretely, if you use assertions like `assert!`, `assert_ne!`, etc., ensure the test logs the relevant actual/expected values (or context) before/around the assertion so failures can be diagnosed without rerunning.

Applied to files:

  • crates/package-manager/src/install_package_from_registry/tests.rs
  • crates/package-manager/src/hoist/tests.rs
📚 Learning: 2026-05-07T14:24:47.105Z
Learnt from: zkochan
Repo: pnpm/pacquet PR: 391
File: crates/cli/tests/lifecycle_scripts.rs:0-0
Timestamp: 2026-05-07T14:24:47.105Z
Learning: In pnpm/pacquet CLI lifecycle tests, note that `AutoMockInstance::load_or_init` returns an anchor to a shared singleton mock registry process. If a test spawns a secondary `CommandTempCwd::init().add_mocked_registry()` (e.g., to run a reinstall with `--frozen-lockfile`), the secondary/inner `mock_instance` may be dropped safely as long as the primary/outer `mock_instance` remains in scope (so the singleton registry stays alive). Separately retain the inner `TempDir` (e.g., via a `frozen_root` binding) so the workspace lives for the duration of the command.

Applied to files:

  • crates/cli/tests/hoist.rs
🔇 Additional comments (21)
plans/TEST_PORTING.md (1)

100-127: LGTM!

Also applies to: 131-132

crates/config/src/matcher.rs (1)

1-409: LGTM!

crates/config/src/lib.rs (1)

2-2: LGTM!

Also applies to: 141-166

crates/package-manager/src/install_package_from_registry/tests.rs (1)

20-21: LGTM!

crates/package-manager/src/hoist.rs (1)

1-460: LGTM!

crates/package-manager/src/lib.rs (1)

11-11: LGTM!

Also applies to: 35-35

crates/package-manager/src/hoist/tests.rs (1)

1-435: LGTM!

crates/package-manager/src/install_frozen_lockfile.rs (4)

1-7: LGTM!

Also applies to: 115-115, 395-395


72-85: LGTM!


216-265: LGTM!


126-129: LGTM!

crates/package-manager/src/install_without_lockfile.rs (1)

2-3: LGTM!

Also applies to: 19-19, 67-76, 239-239

crates/package-manager/src/install.rs (3)

1-6: LGTM!


167-203: LGTM!


254-289: LGTM!

crates/cli/tests/hoist.rs (6)

1-34: LGTM!


36-57: LGTM!


66-134: LGTM!


142-243: LGTM!


250-367: LGTM!


369-631: LGTM!

Comment thread crates/config/src/workspace_yaml.rs Outdated
@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 13, 2026

Integrated-Benchmark Report (Linux)

Scenario: Frozen Lockfile

Command Mean [s] Min [s] Max [s] Relative
pacquet@HEAD 2.678 ± 0.058 2.601 2.759 1.05 ± 0.04
pacquet@main 2.554 ± 0.071 2.478 2.698 1.00
pnpm 6.165 ± 0.114 6.049 6.417 2.41 ± 0.08
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 2.67773545922,
      "stddev": 0.05820957782668119,
      "median": 2.6737227419200003,
      "user": 2.56906316,
      "system": 3.6787571800000003,
      "min": 2.60146721192,
      "max": 2.75912799792,
      "times": [
        2.68981413392,
        2.63180198492,
        2.71076498692,
        2.64400825692,
        2.6035184139200003,
        2.6576313499200004,
        2.7394412219200004,
        2.75912799792,
        2.60146721192,
        2.73977903392
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 2.5538927081200002,
      "stddev": 0.07063343141682328,
      "median": 2.5419252189200003,
      "user": 2.55563986,
      "system": 3.5011243800000003,
      "min": 2.47848991492,
      "max": 2.69785538692,
      "times": [
        2.50134348092,
        2.69785538692,
        2.6038465199200003,
        2.5504413289200003,
        2.48349964292,
        2.59521588092,
        2.60243945792,
        2.47848991492,
        2.5334091089200004,
        2.49238635892
      ]
    },
    {
      "command": "pnpm",
      "mean": 6.165193089519999,
      "stddev": 0.11391130025365838,
      "median": 6.14365161642,
      "user": 9.17639686,
      "system": 4.410328780000001,
      "min": 6.049008401919999,
      "max": 6.41686195692,
      "times": [
        6.16457974192,
        6.232049527919999,
        6.049008401919999,
        6.26413996592,
        6.111883048919999,
        6.162859613919999,
        6.41686195692,
        6.050562643919999,
        6.12444361892,
        6.0755423749199995
      ]
    }
  ]
}

Scenario: Frozen Lockfile (Hot Cache)

Command Mean [ms] Min [ms] Max [ms] Relative
pacquet@HEAD 724.1 ± 35.3 698.3 819.0 1.00
pacquet@main 752.1 ± 27.1 719.9 793.6 1.04 ± 0.06
pnpm 2582.5 ± 99.8 2499.2 2838.8 3.57 ± 0.22
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 0.72409764744,
      "stddev": 0.035296770395962344,
      "median": 0.71150619684,
      "user": 0.37126145999999993,
      "system": 1.5794607399999996,
      "min": 0.6983492988400001,
      "max": 0.8190021388400001,
      "times": [
        0.8190021388400001,
        0.7333036528400001,
        0.7057664158400001,
        0.70391616584,
        0.7065252848400001,
        0.71985212884,
        0.7083599378400001,
        0.71465245584,
        0.6983492988400001,
        0.73124899484
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 0.7520753165400001,
      "stddev": 0.027140140130620584,
      "median": 0.7507104318400002,
      "user": 0.35557496000000005,
      "system": 1.4769795399999999,
      "min": 0.7199046968400001,
      "max": 0.7935854678400001,
      "times": [
        0.7644278348400001,
        0.73006427784,
        0.7611183188400001,
        0.7199046968400001,
        0.7786677228400001,
        0.7242942828400001,
        0.7403025448400001,
        0.7935854678400001,
        0.7818153708400001,
        0.7265726478400001
      ]
    },
    {
      "command": "pnpm",
      "mean": 2.5824919167399996,
      "stddev": 0.09976464169638383,
      "median": 2.55900032984,
      "user": 3.17489806,
      "system": 2.14910604,
      "min": 2.4991594408399997,
      "max": 2.83884071184,
      "times": [
        2.6433046558399997,
        2.57191786484,
        2.55115563984,
        2.83884071184,
        2.4991594408399997,
        2.51945165784,
        2.56684501984,
        2.50405790684,
        2.58906871084,
        2.54111755884
      ]
    }
  ]
}

zkochan added a commit that referenced this pull request May 13, 2026
… bin pass

Address PR #445 review feedback.

- **Link public-hoisted bins** (Copilot). Hoist now collects
  `publicly_hoisted_aliases_with_bins` alongside the private side.
  `InstallFrozenLockfile::run` runs a second `link_direct_dep_bins`
  pass against `<root>/node_modules` after the hoist symlinks land,
  so public-hoisted bins reach `<root>/node_modules/.bin` even
  though pacquet's `SymlinkDirectDependencies` runs *before* the
  hoist (upstream's order is reversed).

- **Tri-state null hoist patterns** (Copilot, coderabbit). YAML
  fields become `Option<Option<Vec<String>>>` via a small
  `deserialize_double_option` helper. Now distinguishes:
  - missing key → outer `None` → leave config defaults in place
  - explicit `null` → `Some(None)` → disable that hoist side
  - explicit list → `Some(Some(vec))` → override
  Mirrors upstream's `hoistPattern != null` guard semantics.
  Workspace_yaml `apply_to` uses the inner option directly.

- **Reuse `link_direct_dep_bins`** (Copilot). Drop the sequential
  `link_hoisted_bins` helper that duplicated the manifest-read +
  `link_bins_of_packages` flow. Both private and public hoist
  passes now go through the existing rayon-parallel
  `link_direct_dep_bins`.

- **Strengthen `public_hoist_bin_is_linked_via_root_bin_dir` test**
  (Copilot). Now asserts `<root>/node_modules/.bin/hello-world-js-bin`
  exists, not just the alias symlink. Catches the bin-link gap the
  first round failed to detect.

- Add `hoist_patterns_tri_state_round_trip` regression test in
  `workspace_yaml::tests` covering all three serde shapes (missing,
  null, list) for both pattern fields.

Tests: `just ready` 671/671 pass; hoist CLI suite 35/35; matcher
6/6; hoist algorithm 12/12; new tri-state test passes.
zkochan added a commit that referenced this pull request May 13, 2026
… bin pass

Address PR #445 review feedback.

- **Link public-hoisted bins** (Copilot). Hoist now collects
  `publicly_hoisted_aliases_with_bins` alongside the private side.
  `InstallFrozenLockfile::run` runs a second `link_direct_dep_bins`
  pass against `<root>/node_modules` after the hoist symlinks land,
  so public-hoisted bins reach `<root>/node_modules/.bin` even
  though pacquet's `SymlinkDirectDependencies` runs *before* the
  hoist (upstream's order is reversed).

- **Tri-state null hoist patterns** (Copilot, coderabbit). YAML
  fields become `Option<Option<Vec<String>>>` via a small
  `deserialize_double_option` helper. Now distinguishes:
  - missing key → outer `None` → leave config defaults in place
  - explicit `null` → `Some(None)` → disable that hoist side
  - explicit list → `Some(Some(vec))` → override
  Mirrors upstream's `hoistPattern != null` guard semantics.
  Workspace_yaml `apply_to` uses the inner option directly.

- **Reuse `link_direct_dep_bins`** (Copilot). Drop the sequential
  `link_hoisted_bins` helper that duplicated the manifest-read +
  `link_bins_of_packages` flow. Both private and public hoist
  passes now go through the existing rayon-parallel
  `link_direct_dep_bins`.

- **Strengthen `public_hoist_bin_is_linked_via_root_bin_dir` test**
  (Copilot). Now asserts `<root>/node_modules/.bin/hello-world-js-bin`
  exists, not just the alias symlink. Catches the bin-link gap the
  first round failed to detect.

- Add `hoist_patterns_tri_state_round_trip` regression test in
  `workspace_yaml::tests` covering all three serde shapes (missing,
  null, list) for both pattern fields.

Tests: `just ready` 671/671 pass; hoist CLI suite 35/35; matcher
6/6; hoist algorithm 12/12; new tri-state test passes.
Copilot AI review requested due to automatic review settings May 13, 2026 11:53
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated 3 comments.

Comment thread crates/package-manager/src/install_frozen_lockfile.rs Outdated
Comment thread crates/config/src/matcher.rs Outdated
Comment thread crates/cli/tests/hoist.rs
zkochan added a commit that referenced this pull request May 13, 2026
…cleanup

Address PR #445 follow-up review and the failed Doc CI job.

**Doc check** (CI gate)
- Drop the broken intra-doc link `crate::link_bins::link_bins_of_packages`
  and the dangling `link_hoisted_bins` reference (deleted in eeda321).
  Module-level doc now points at `crate::link_direct_dep_bins` via the
  call site.
- Disambiguate `crate::symlink_package` (both a module and a function)
  with `()` to pin the function form.
- Replace the `[\`deserialize_double_option\`]` link from public
  `WorkspaceSettings::hoist_pattern` with prose — the helper is
  intentionally private and `-D rustdoc::private-intra-doc-links`
  rejects the link from a public item.

**Restrict hoist input to root importer** (Copilot)
- `build_direct_deps_by_importer` now takes `IntoIterator<Item =
  (&String, &ProjectSnapshot)>` instead of `&HashMap<...>`. The
  frozen-lockfile call site filters down to the single root importer
  via `importers.get_key_value(ROOT_IMPORTER_KEY)`. With workspace
  install (#431) not yet implemented, hoisting transitives of
  non-root importers would have polluted the root project's
  `node_modules` with packages that aren't actually installed.

**Correct `MatcherImpl::Mixed` doc comment** (Copilot)
- Old comment said "a subsequent include does not re-take the sticky
  slot" — wrong. The implementation lets a later include re-take
  after a matching ignore clears the sticky slot, which is exactly
  what the `eslint-*`, `!eslint-plugin-*`, `eslint-plugin-bar` test
  case relies on. Comment now describes the actual "first include
  after the last matching ignore wins" behavior with the test case
  cited.

**Dedicated `external_symlink_introspection` known_failure** (Copilot)
- `public_hoist_preserves_existing_root_directories` was gated on
  `partial_install_persists_hoisted_map()` even though its doc
  describes the missing `resolveLinkTarget` + `isSubdir`
  introspection. Added a dedicated `external_symlink_introspection`
  reason and pointed the stub at it so the gate matches the actual
  blocker.

Tests: `just ready` 705/705 pass; `cargo doc --workspace --no-deps`
with `RUSTDOCFLAGS="-D warnings"` clean; `just dylint` clean.
Copilot AI review requested due to automatic review settings May 13, 2026 12:30
zkochan added a commit that referenced this pull request May 13, 2026
… bin pass

Address PR #445 review feedback.

- **Link public-hoisted bins** (Copilot). Hoist now collects
  `publicly_hoisted_aliases_with_bins` alongside the private side.
  `InstallFrozenLockfile::run` runs a second `link_direct_dep_bins`
  pass against `<root>/node_modules` after the hoist symlinks land,
  so public-hoisted bins reach `<root>/node_modules/.bin` even
  though pacquet's `SymlinkDirectDependencies` runs *before* the
  hoist (upstream's order is reversed).

- **Tri-state null hoist patterns** (Copilot, coderabbit). YAML
  fields become `Option<Option<Vec<String>>>` via a small
  `deserialize_double_option` helper. Now distinguishes:
  - missing key → outer `None` → leave config defaults in place
  - explicit `null` → `Some(None)` → disable that hoist side
  - explicit list → `Some(Some(vec))` → override
  Mirrors upstream's `hoistPattern != null` guard semantics.
  Workspace_yaml `apply_to` uses the inner option directly.

- **Reuse `link_direct_dep_bins`** (Copilot). Drop the sequential
  `link_hoisted_bins` helper that duplicated the manifest-read +
  `link_bins_of_packages` flow. Both private and public hoist
  passes now go through the existing rayon-parallel
  `link_direct_dep_bins`.

- **Strengthen `public_hoist_bin_is_linked_via_root_bin_dir` test**
  (Copilot). Now asserts `<root>/node_modules/.bin/hello-world-js-bin`
  exists, not just the alias symlink. Catches the bin-link gap the
  first round failed to detect.

- Add `hoist_patterns_tri_state_round_trip` regression test in
  `workspace_yaml::tests` covering all three serde shapes (missing,
  null, list) for both pattern fields.

Tests: `just ready` 671/671 pass; hoist CLI suite 35/35; matcher
6/6; hoist algorithm 12/12; new tri-state test passes.
zkochan added a commit that referenced this pull request May 13, 2026
…cleanup

Address PR #445 follow-up review and the failed Doc CI job.

**Doc check** (CI gate)
- Drop the broken intra-doc link `crate::link_bins::link_bins_of_packages`
  and the dangling `link_hoisted_bins` reference (deleted in eeda321).
  Module-level doc now points at `crate::link_direct_dep_bins` via the
  call site.
- Disambiguate `crate::symlink_package` (both a module and a function)
  with `()` to pin the function form.
- Replace the `[\`deserialize_double_option\`]` link from public
  `WorkspaceSettings::hoist_pattern` with prose — the helper is
  intentionally private and `-D rustdoc::private-intra-doc-links`
  rejects the link from a public item.

**Restrict hoist input to root importer** (Copilot)
- `build_direct_deps_by_importer` now takes `IntoIterator<Item =
  (&String, &ProjectSnapshot)>` instead of `&HashMap<...>`. The
  frozen-lockfile call site filters down to the single root importer
  via `importers.get_key_value(ROOT_IMPORTER_KEY)`. With workspace
  install (#431) not yet implemented, hoisting transitives of
  non-root importers would have polluted the root project's
  `node_modules` with packages that aren't actually installed.

**Correct `MatcherImpl::Mixed` doc comment** (Copilot)
- Old comment said "a subsequent include does not re-take the sticky
  slot" — wrong. The implementation lets a later include re-take
  after a matching ignore clears the sticky slot, which is exactly
  what the `eslint-*`, `!eslint-plugin-*`, `eslint-plugin-bar` test
  case relies on. Comment now describes the actual "first include
  after the last matching ignore wins" behavior with the test case
  cited.

**Dedicated `external_symlink_introspection` known_failure** (Copilot)
- `public_hoist_preserves_existing_root_directories` was gated on
  `partial_install_persists_hoisted_map()` even though its doc
  describes the missing `resolveLinkTarget` + `isSubdir`
  introspection. Added a dedicated `external_symlink_introspection`
  reason and pointed the stub at it so the gate matches the actual
  blocker.

Tests: `just ready` 705/705 pass; `cargo doc --workspace --no-deps`
with `RUSTDOCFLAGS="-D warnings"` clean; `just dylint` clean.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated 3 comments.

Comment thread crates/package-manager/src/hoist.rs
Comment thread crates/package-manager/src/install_frozen_lockfile.rs
Comment thread crates/package-manager/src/hoist/tests.rs Outdated
zkochan added a commit that referenced this pull request May 13, 2026
Address PR #445 review feedback (round 3).

- **`Config.hoist == false` now nullifies `hoist_pattern`** (Copilot).
  `WorkspaceSettings::apply_to` runs an `!config.hoist ⇒
  config.hoist_pattern = None` post-step after assigning the per-
  field overrides. Mirrors upstream's
  [`projectConfig.ts:72-75`](https://github.com/pnpm/pnpm/blob/94240bc046/config/reader/src/projectConfig.ts#L72-L75)
  `result.hoist === false ⇒ hoistPattern: undefined`.
  `public_hoist_pattern` is intentionally untouched (upstream
  doesn't nullify it either; public hoisting is governed by its own
  pattern + `shamefullyHoist`). Setting `hoist: false` in
  `pnpm-workspace.yaml` now correctly short-circuits private
  hoisting via the install-time `is_some() || is_some()` guard,
  even when the default `Some(["*"])` pattern would otherwise apply.

- **`PkgName::clone(name)` → `name.clone()`** (Copilot style nit).
  Both forms compile identically (`Type::method(value)` is fully-
  qualified syntax for trait methods), but method-call form is
  consistent with the rest of the file.

- **Fix misleading test doc comment** (Copilot).
  `skipped_snapshot_is_excluded`'s comment said the symlink pass
  "attempts and fails gracefully on the missing node" — wrong on
  two counts: `symlink_hoisted_dependencies` skips missing nodes
  via an early `let Some(_) else { continue }` (no attempt), and
  `symlink_package` on Unix would create a dangling symlink rather
  than fail anyway. Updated to describe the actual skip + cite
  upstream's pre-skip recording behavior.

- New regression test `hoist_false_disables_private_hoist_pattern`
  in `workspace_yaml::tests` covers `hoist: false` alone (drops the
  default pattern, leaves public alone) and `hoist: false` paired
  with an explicit `hoistPattern` (drops the explicit value too).

Tests: `just ready` 756/756 pass; `cargo doc --workspace --no-deps`
with `RUSTDOCFLAGS="-D warnings"` clean; `just dylint` clean.
zkochan added a commit that referenced this pull request May 13, 2026
… bin pass

Address PR #445 review feedback.

- **Link public-hoisted bins** (Copilot). Hoist now collects
  `publicly_hoisted_aliases_with_bins` alongside the private side.
  `InstallFrozenLockfile::run` runs a second `link_direct_dep_bins`
  pass against `<root>/node_modules` after the hoist symlinks land,
  so public-hoisted bins reach `<root>/node_modules/.bin` even
  though pacquet's `SymlinkDirectDependencies` runs *before* the
  hoist (upstream's order is reversed).

- **Tri-state null hoist patterns** (Copilot, coderabbit). YAML
  fields become `Option<Option<Vec<String>>>` via a small
  `deserialize_double_option` helper. Now distinguishes:
  - missing key → outer `None` → leave config defaults in place
  - explicit `null` → `Some(None)` → disable that hoist side
  - explicit list → `Some(Some(vec))` → override
  Mirrors upstream's `hoistPattern != null` guard semantics.
  Workspace_yaml `apply_to` uses the inner option directly.

- **Reuse `link_direct_dep_bins`** (Copilot). Drop the sequential
  `link_hoisted_bins` helper that duplicated the manifest-read +
  `link_bins_of_packages` flow. Both private and public hoist
  passes now go through the existing rayon-parallel
  `link_direct_dep_bins`.

- **Strengthen `public_hoist_bin_is_linked_via_root_bin_dir` test**
  (Copilot). Now asserts `<root>/node_modules/.bin/hello-world-js-bin`
  exists, not just the alias symlink. Catches the bin-link gap the
  first round failed to detect.

- Add `hoist_patterns_tri_state_round_trip` regression test in
  `workspace_yaml::tests` covering all three serde shapes (missing,
  null, list) for both pattern fields.

Tests: `just ready` 671/671 pass; hoist CLI suite 35/35; matcher
6/6; hoist algorithm 12/12; new tri-state test passes.
zkochan added a commit that referenced this pull request May 13, 2026
…cleanup

Address PR #445 follow-up review and the failed Doc CI job.

**Doc check** (CI gate)
- Drop the broken intra-doc link `crate::link_bins::link_bins_of_packages`
  and the dangling `link_hoisted_bins` reference (deleted in eeda321).
  Module-level doc now points at `crate::link_direct_dep_bins` via the
  call site.
- Disambiguate `crate::symlink_package` (both a module and a function)
  with `()` to pin the function form.
- Replace the `[\`deserialize_double_option\`]` link from public
  `WorkspaceSettings::hoist_pattern` with prose — the helper is
  intentionally private and `-D rustdoc::private-intra-doc-links`
  rejects the link from a public item.

**Restrict hoist input to root importer** (Copilot)
- `build_direct_deps_by_importer` now takes `IntoIterator<Item =
  (&String, &ProjectSnapshot)>` instead of `&HashMap<...>`. The
  frozen-lockfile call site filters down to the single root importer
  via `importers.get_key_value(ROOT_IMPORTER_KEY)`. With workspace
  install (#431) not yet implemented, hoisting transitives of
  non-root importers would have polluted the root project's
  `node_modules` with packages that aren't actually installed.

**Correct `MatcherImpl::Mixed` doc comment** (Copilot)
- Old comment said "a subsequent include does not re-take the sticky
  slot" — wrong. The implementation lets a later include re-take
  after a matching ignore clears the sticky slot, which is exactly
  what the `eslint-*`, `!eslint-plugin-*`, `eslint-plugin-bar` test
  case relies on. Comment now describes the actual "first include
  after the last matching ignore wins" behavior with the test case
  cited.

**Dedicated `external_symlink_introspection` known_failure** (Copilot)
- `public_hoist_preserves_existing_root_directories` was gated on
  `partial_install_persists_hoisted_map()` even though its doc
  describes the missing `resolveLinkTarget` + `isSubdir`
  introspection. Added a dedicated `external_symlink_introspection`
  reason and pointed the stub at it so the gate matches the actual
  blocker.

Tests: `just ready` 705/705 pass; `cargo doc --workspace --no-deps`
with `RUSTDOCFLAGS="-D warnings"` clean; `just dylint` clean.
zkochan added a commit that referenced this pull request May 13, 2026
Address PR #445 review feedback (round 3).

- **`Config.hoist == false` now nullifies `hoist_pattern`** (Copilot).
  `WorkspaceSettings::apply_to` runs an `!config.hoist ⇒
  config.hoist_pattern = None` post-step after assigning the per-
  field overrides. Mirrors upstream's
  [`projectConfig.ts:72-75`](https://github.com/pnpm/pnpm/blob/94240bc046/config/reader/src/projectConfig.ts#L72-L75)
  `result.hoist === false ⇒ hoistPattern: undefined`.
  `public_hoist_pattern` is intentionally untouched (upstream
  doesn't nullify it either; public hoisting is governed by its own
  pattern + `shamefullyHoist`). Setting `hoist: false` in
  `pnpm-workspace.yaml` now correctly short-circuits private
  hoisting via the install-time `is_some() || is_some()` guard,
  even when the default `Some(["*"])` pattern would otherwise apply.

- **`PkgName::clone(name)` → `name.clone()`** (Copilot style nit).
  Both forms compile identically (`Type::method(value)` is fully-
  qualified syntax for trait methods), but method-call form is
  consistent with the rest of the file.

- **Fix misleading test doc comment** (Copilot).
  `skipped_snapshot_is_excluded`'s comment said the symlink pass
  "attempts and fails gracefully on the missing node" — wrong on
  two counts: `symlink_hoisted_dependencies` skips missing nodes
  via an early `let Some(_) else { continue }` (no attempt), and
  `symlink_package` on Unix would create a dangling symlink rather
  than fail anyway. Updated to describe the actual skip + cite
  upstream's pre-skip recording behavior.

- New regression test `hoist_false_disables_private_hoist_pattern`
  in `workspace_yaml::tests` covers `hoist: false` alone (drops the
  default pattern, leaves public alone) and `hoist: false` paired
  with an explicit `hoistPattern` (drops the explicit value too).

Tests: `just ready` 756/756 pass; `cargo doc --workspace --no-deps`
with `RUSTDOCFLAGS="-D warnings"` clean; `just dylint` clean.
Copilot AI review requested due to automatic review settings May 13, 2026 13:31
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

zkochan added a commit that referenced this pull request May 13, 2026
Origin/main grew a `path: Option<String>` field on
`TarballResolution` (PR #451, `feat(git-fetcher): install
git-hosted tarballs via preparePackage + packlist`). That PR
landed after #439 (`feat(package-is-installable): platform +
engine check`) and missed updating the `TarballResolution`
literal in `crates/package-manager/src/installability/tests.rs`,
so any PR opened against current main inherits a build break in
the `package-is-installable` test build (#445 hit the same
failure). My own `peer_dependency_in_lockfile_surfaces_unsupported`
test in `crates/real-hoist/src/tests.rs` had the same gap.

Add `path: None` to both literals so the test builds compile
against the post-#451 `TarballResolution` shape.

Includes the rebase of feat/438-slice-3b onto current origin/main.
zkochan added a commit that referenced this pull request May 13, 2026
…cleanup

Address PR #445 follow-up review and the failed Doc CI job.

**Doc check** (CI gate)
- Drop the broken intra-doc link `crate::link_bins::link_bins_of_packages`
  and the dangling `link_hoisted_bins` reference (deleted in eeda321).
  Module-level doc now points at `crate::link_direct_dep_bins` via the
  call site.
- Disambiguate `crate::symlink_package` (both a module and a function)
  with `()` to pin the function form.
- Replace the `[\`deserialize_double_option\`]` link from public
  `WorkspaceSettings::hoist_pattern` with prose — the helper is
  intentionally private and `-D rustdoc::private-intra-doc-links`
  rejects the link from a public item.

**Restrict hoist input to root importer** (Copilot)
- `build_direct_deps_by_importer` now takes `IntoIterator<Item =
  (&String, &ProjectSnapshot)>` instead of `&HashMap<...>`. The
  frozen-lockfile call site filters down to the single root importer
  via `importers.get_key_value(ROOT_IMPORTER_KEY)`. With workspace
  install (#431) not yet implemented, hoisting transitives of
  non-root importers would have polluted the root project's
  `node_modules` with packages that aren't actually installed.

**Correct `MatcherImpl::Mixed` doc comment** (Copilot)
- Old comment said "a subsequent include does not re-take the sticky
  slot" — wrong. The implementation lets a later include re-take
  after a matching ignore clears the sticky slot, which is exactly
  what the `eslint-*`, `!eslint-plugin-*`, `eslint-plugin-bar` test
  case relies on. Comment now describes the actual "first include
  after the last matching ignore wins" behavior with the test case
  cited.

**Dedicated `external_symlink_introspection` known_failure** (Copilot)
- `public_hoist_preserves_existing_root_directories` was gated on
  `partial_install_persists_hoisted_map()` even though its doc
  describes the missing `resolveLinkTarget` + `isSubdir`
  introspection. Added a dedicated `external_symlink_introspection`
  reason and pointed the stub at it so the gate matches the actual
  blocker.

Tests: `just ready` 705/705 pass; `cargo doc --workspace --no-deps`
with `RUSTDOCFLAGS="-D warnings"` clean; `just dylint` clean.
zkochan added a commit that referenced this pull request May 13, 2026
Address PR #445 review feedback (round 3).

- **`Config.hoist == false` now nullifies `hoist_pattern`** (Copilot).
  `WorkspaceSettings::apply_to` runs an `!config.hoist ⇒
  config.hoist_pattern = None` post-step after assigning the per-
  field overrides. Mirrors upstream's
  [`projectConfig.ts:72-75`](https://github.com/pnpm/pnpm/blob/94240bc046/config/reader/src/projectConfig.ts#L72-L75)
  `result.hoist === false ⇒ hoistPattern: undefined`.
  `public_hoist_pattern` is intentionally untouched (upstream
  doesn't nullify it either; public hoisting is governed by its own
  pattern + `shamefullyHoist`). Setting `hoist: false` in
  `pnpm-workspace.yaml` now correctly short-circuits private
  hoisting via the install-time `is_some() || is_some()` guard,
  even when the default `Some(["*"])` pattern would otherwise apply.

- **`PkgName::clone(name)` → `name.clone()`** (Copilot style nit).
  Both forms compile identically (`Type::method(value)` is fully-
  qualified syntax for trait methods), but method-call form is
  consistent with the rest of the file.

- **Fix misleading test doc comment** (Copilot).
  `skipped_snapshot_is_excluded`'s comment said the symlink pass
  "attempts and fails gracefully on the missing node" — wrong on
  two counts: `symlink_hoisted_dependencies` skips missing nodes
  via an early `let Some(_) else { continue }` (no attempt), and
  `symlink_package` on Unix would create a dangling symlink rather
  than fail anyway. Updated to describe the actual skip + cite
  upstream's pre-skip recording behavior.

- New regression test `hoist_false_disables_private_hoist_pattern`
  in `workspace_yaml::tests` covers `hoist: false` alone (drops the
  default pattern, leaves public alone) and `hoist: false` paired
  with an explicit `hoistPattern` (drops the explicit value too).

Tests: `just ready` 756/756 pass; `cargo doc --workspace --no-deps`
with `RUSTDOCFLAGS="-D warnings"` clean; `just dylint` clean.
Copilot AI review requested due to automatic review settings May 13, 2026 14:36
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

zkochan added a commit that referenced this pull request May 13, 2026
… bin pass

Address PR #445 review feedback.

- **Link public-hoisted bins** (Copilot). Hoist now collects
  `publicly_hoisted_aliases_with_bins` alongside the private side.
  `InstallFrozenLockfile::run` runs a second `link_direct_dep_bins`
  pass against `<root>/node_modules` after the hoist symlinks land,
  so public-hoisted bins reach `<root>/node_modules/.bin` even
  though pacquet's `SymlinkDirectDependencies` runs *before* the
  hoist (upstream's order is reversed).

- **Tri-state null hoist patterns** (Copilot, coderabbit). YAML
  fields become `Option<Option<Vec<String>>>` via a small
  `deserialize_double_option` helper. Now distinguishes:
  - missing key → outer `None` → leave config defaults in place
  - explicit `null` → `Some(None)` → disable that hoist side
  - explicit list → `Some(Some(vec))` → override
  Mirrors upstream's `hoistPattern != null` guard semantics.
  Workspace_yaml `apply_to` uses the inner option directly.

- **Reuse `link_direct_dep_bins`** (Copilot). Drop the sequential
  `link_hoisted_bins` helper that duplicated the manifest-read +
  `link_bins_of_packages` flow. Both private and public hoist
  passes now go through the existing rayon-parallel
  `link_direct_dep_bins`.

- **Strengthen `public_hoist_bin_is_linked_via_root_bin_dir` test**
  (Copilot). Now asserts `<root>/node_modules/.bin/hello-world-js-bin`
  exists, not just the alias symlink. Catches the bin-link gap the
  first round failed to detect.

- Add `hoist_patterns_tri_state_round_trip` regression test in
  `workspace_yaml::tests` covering all three serde shapes (missing,
  null, list) for both pattern fields.

Tests: `just ready` 671/671 pass; hoist CLI suite 35/35; matcher
6/6; hoist algorithm 12/12; new tri-state test passes.
zkochan added a commit that referenced this pull request May 13, 2026
…cleanup

Address PR #445 follow-up review and the failed Doc CI job.

**Doc check** (CI gate)
- Drop the broken intra-doc link `crate::link_bins::link_bins_of_packages`
  and the dangling `link_hoisted_bins` reference (deleted in eeda321).
  Module-level doc now points at `crate::link_direct_dep_bins` via the
  call site.
- Disambiguate `crate::symlink_package` (both a module and a function)
  with `()` to pin the function form.
- Replace the `[\`deserialize_double_option\`]` link from public
  `WorkspaceSettings::hoist_pattern` with prose — the helper is
  intentionally private and `-D rustdoc::private-intra-doc-links`
  rejects the link from a public item.

**Restrict hoist input to root importer** (Copilot)
- `build_direct_deps_by_importer` now takes `IntoIterator<Item =
  (&String, &ProjectSnapshot)>` instead of `&HashMap<...>`. The
  frozen-lockfile call site filters down to the single root importer
  via `importers.get_key_value(ROOT_IMPORTER_KEY)`. With workspace
  install (#431) not yet implemented, hoisting transitives of
  non-root importers would have polluted the root project's
  `node_modules` with packages that aren't actually installed.

**Correct `MatcherImpl::Mixed` doc comment** (Copilot)
- Old comment said "a subsequent include does not re-take the sticky
  slot" — wrong. The implementation lets a later include re-take
  after a matching ignore clears the sticky slot, which is exactly
  what the `eslint-*`, `!eslint-plugin-*`, `eslint-plugin-bar` test
  case relies on. Comment now describes the actual "first include
  after the last matching ignore wins" behavior with the test case
  cited.

**Dedicated `external_symlink_introspection` known_failure** (Copilot)
- `public_hoist_preserves_existing_root_directories` was gated on
  `partial_install_persists_hoisted_map()` even though its doc
  describes the missing `resolveLinkTarget` + `isSubdir`
  introspection. Added a dedicated `external_symlink_introspection`
  reason and pointed the stub at it so the gate matches the actual
  blocker.

Tests: `just ready` 705/705 pass; `cargo doc --workspace --no-deps`
with `RUSTDOCFLAGS="-D warnings"` clean; `just dylint` clean.
zkochan added a commit that referenced this pull request May 13, 2026
Address PR #445 review feedback (round 3).

- **`Config.hoist == false` now nullifies `hoist_pattern`** (Copilot).
  `WorkspaceSettings::apply_to` runs an `!config.hoist ⇒
  config.hoist_pattern = None` post-step after assigning the per-
  field overrides. Mirrors upstream's
  [`projectConfig.ts:72-75`](https://github.com/pnpm/pnpm/blob/94240bc046/config/reader/src/projectConfig.ts#L72-L75)
  `result.hoist === false ⇒ hoistPattern: undefined`.
  `public_hoist_pattern` is intentionally untouched (upstream
  doesn't nullify it either; public hoisting is governed by its own
  pattern + `shamefullyHoist`). Setting `hoist: false` in
  `pnpm-workspace.yaml` now correctly short-circuits private
  hoisting via the install-time `is_some() || is_some()` guard,
  even when the default `Some(["*"])` pattern would otherwise apply.

- **`PkgName::clone(name)` → `name.clone()`** (Copilot style nit).
  Both forms compile identically (`Type::method(value)` is fully-
  qualified syntax for trait methods), but method-call form is
  consistent with the rest of the file.

- **Fix misleading test doc comment** (Copilot).
  `skipped_snapshot_is_excluded`'s comment said the symlink pass
  "attempts and fails gracefully on the missing node" — wrong on
  two counts: `symlink_hoisted_dependencies` skips missing nodes
  via an early `let Some(_) else { continue }` (no attempt), and
  `symlink_package` on Unix would create a dangling symlink rather
  than fail anyway. Updated to describe the actual skip + cite
  upstream's pre-skip recording behavior.

- New regression test `hoist_false_disables_private_hoist_pattern`
  in `workspace_yaml::tests` covers `hoist: false` alone (drops the
  default pattern, leaves public alone) and `hoist: false` paired
  with an explicit `hoistPattern` (drops the explicit value too).

Tests: `just ready` 756/756 pass; `cargo doc --workspace --no-deps`
with `RUSTDOCFLAGS="-D warnings"` clean; `just dylint` clean.
Copilot AI review requested due to automatic review settings May 13, 2026 15:19
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated 4 comments.

Comment thread crates/package-manager/src/install_frozen_lockfile.rs Outdated
Comment thread crates/package-manager/src/install.rs
Comment thread crates/package-manager/src/hoist.rs Outdated
Comment thread crates/cli/tests/hoist.rs Outdated
zkochan added 10 commits May 13, 2026 17:33
Port pnpm v11's hoist pass into pacquet's frozen-lockfile install
path. Closes #435.

- `pacquet_config::matcher` ports `@pnpm/config.matcher` (`*`-only
  glob, `!`-negation, first-match-wins; no regex dependency).
- `pacquet_package_manager::hoist` ports `getHoistedDependencies` and
  `symlinkHoistedDependencies` from
  `installing/linking/hoist/src/index.ts`. The BFS, sort by
  (depth, nodeId), public-wins-ties, first-seen-wins-per-alias, and
  root-importer-direct-dep seed all mirror upstream.
- Hoist patterns become `Option<Vec<String>>` on `Config` so the
  install-time `is_some() || is_some()` guard mirrors upstream's
  `!= null` check.
- `InstallFrozenLockfile::run` returns `HoistedDependencies` and
  threads it into `.modules.yaml` via `Install::run`. The
  without-lockfile path returns an empty map (no graph to walk).
- Symlinks land at `<vs>/node_modules/<alias>` (private) and
  `<root>/node_modules/<alias>` (public). Private-side bins go into
  `<vs>/node_modules/.bin/`; public-side bins reuse the existing
  direct-deps bin pass.
- Out of scope per the issue: GVS (#432), workspace install (#431),
  partial install (#433), and `nodeLinker: hoisted`. Tests that
  depend on those features live in `crates/cli/tests/hoist.rs`'s
  `known_failures` module via `allow_known_failure!`.

Tests: 6 matcher unit tests, 12 hoist algorithm unit tests, 9 CLI
end-to-end tests, and 17 `known_failures` stubs porting the
remainder of `installing/deps-installer/test/install/hoist.ts`.
`plans/TEST_PORTING.md` updated to mark the ported entries.

Upstream pinned at pnpm v11 `94240bc046`.
… bin pass

Address PR #445 review feedback.

- **Link public-hoisted bins** (Copilot). Hoist now collects
  `publicly_hoisted_aliases_with_bins` alongside the private side.
  `InstallFrozenLockfile::run` runs a second `link_direct_dep_bins`
  pass against `<root>/node_modules` after the hoist symlinks land,
  so public-hoisted bins reach `<root>/node_modules/.bin` even
  though pacquet's `SymlinkDirectDependencies` runs *before* the
  hoist (upstream's order is reversed).

- **Tri-state null hoist patterns** (Copilot, coderabbit). YAML
  fields become `Option<Option<Vec<String>>>` via a small
  `deserialize_double_option` helper. Now distinguishes:
  - missing key → outer `None` → leave config defaults in place
  - explicit `null` → `Some(None)` → disable that hoist side
  - explicit list → `Some(Some(vec))` → override
  Mirrors upstream's `hoistPattern != null` guard semantics.
  Workspace_yaml `apply_to` uses the inner option directly.

- **Reuse `link_direct_dep_bins`** (Copilot). Drop the sequential
  `link_hoisted_bins` helper that duplicated the manifest-read +
  `link_bins_of_packages` flow. Both private and public hoist
  passes now go through the existing rayon-parallel
  `link_direct_dep_bins`.

- **Strengthen `public_hoist_bin_is_linked_via_root_bin_dir` test**
  (Copilot). Now asserts `<root>/node_modules/.bin/hello-world-js-bin`
  exists, not just the alias symlink. Catches the bin-link gap the
  first round failed to detect.

- Add `hoist_patterns_tri_state_round_trip` regression test in
  `workspace_yaml::tests` covering all three serde shapes (missing,
  null, list) for both pattern fields.

Tests: `just ready` 671/671 pass; hoist CLI suite 35/35; matcher
6/6; hoist algorithm 12/12; new tri-state test passes.
…cleanup

Address PR #445 follow-up review and the failed Doc CI job.

**Doc check** (CI gate)
- Drop the broken intra-doc link `crate::link_bins::link_bins_of_packages`
  and the dangling `link_hoisted_bins` reference (deleted in eeda321).
  Module-level doc now points at `crate::link_direct_dep_bins` via the
  call site.
- Disambiguate `crate::symlink_package` (both a module and a function)
  with `()` to pin the function form.
- Replace the `[\`deserialize_double_option\`]` link from public
  `WorkspaceSettings::hoist_pattern` with prose — the helper is
  intentionally private and `-D rustdoc::private-intra-doc-links`
  rejects the link from a public item.

**Restrict hoist input to root importer** (Copilot)
- `build_direct_deps_by_importer` now takes `IntoIterator<Item =
  (&String, &ProjectSnapshot)>` instead of `&HashMap<...>`. The
  frozen-lockfile call site filters down to the single root importer
  via `importers.get_key_value(ROOT_IMPORTER_KEY)`. With workspace
  install (#431) not yet implemented, hoisting transitives of
  non-root importers would have polluted the root project's
  `node_modules` with packages that aren't actually installed.

**Correct `MatcherImpl::Mixed` doc comment** (Copilot)
- Old comment said "a subsequent include does not re-take the sticky
  slot" — wrong. The implementation lets a later include re-take
  after a matching ignore clears the sticky slot, which is exactly
  what the `eslint-*`, `!eslint-plugin-*`, `eslint-plugin-bar` test
  case relies on. Comment now describes the actual "first include
  after the last matching ignore wins" behavior with the test case
  cited.

**Dedicated `external_symlink_introspection` known_failure** (Copilot)
- `public_hoist_preserves_existing_root_directories` was gated on
  `partial_install_persists_hoisted_map()` even though its doc
  describes the missing `resolveLinkTarget` + `isSubdir`
  introspection. Added a dedicated `external_symlink_introspection`
  reason and pointed the stub at it so the gate matches the actual
  blocker.

Tests: `just ready` 705/705 pass; `cargo doc --workspace --no-deps`
with `RUSTDOCFLAGS="-D warnings"` clean; `just dylint` clean.
Address PR #445 review feedback (round 3).

- **`Config.hoist == false` now nullifies `hoist_pattern`** (Copilot).
  `WorkspaceSettings::apply_to` runs an `!config.hoist ⇒
  config.hoist_pattern = None` post-step after assigning the per-
  field overrides. Mirrors upstream's
  [`projectConfig.ts:72-75`](https://github.com/pnpm/pnpm/blob/94240bc046/config/reader/src/projectConfig.ts#L72-L75)
  `result.hoist === false ⇒ hoistPattern: undefined`.
  `public_hoist_pattern` is intentionally untouched (upstream
  doesn't nullify it either; public hoisting is governed by its own
  pattern + `shamefullyHoist`). Setting `hoist: false` in
  `pnpm-workspace.yaml` now correctly short-circuits private
  hoisting via the install-time `is_some() || is_some()` guard,
  even when the default `Some(["*"])` pattern would otherwise apply.

- **`PkgName::clone(name)` → `name.clone()`** (Copilot style nit).
  Both forms compile identically (`Type::method(value)` is fully-
  qualified syntax for trait methods), but method-call form is
  consistent with the rest of the file.

- **Fix misleading test doc comment** (Copilot).
  `skipped_snapshot_is_excluded`'s comment said the symlink pass
  "attempts and fails gracefully on the missing node" — wrong on
  two counts: `symlink_hoisted_dependencies` skips missing nodes
  via an early `let Some(_) else { continue }` (no attempt), and
  `symlink_package` on Unix would create a dangling symlink rather
  than fail anyway. Updated to describe the actual skip + cite
  upstream's pre-skip recording behavior.

- New regression test `hoist_false_disables_private_hoist_pattern`
  in `workspace_yaml::tests` covers `hoist: false` alone (drops the
  default pattern, leaves public alone) and `hoist: false` paired
  with an explicit `hoistPattern` (drops the explicit value too).

Tests: `just ready` 756/756 pass; `cargo doc --workspace --no-deps`
with `RUSTDOCFLAGS="-D warnings"` clean; `just dylint` clean.
…hers

Address the ~200ms regression vs main's frozen-lockfile baseline by
trimming the hoist pass's IO and skipping work when the matchers
can't match anything.

Three changes, in order of expected impact:

- **Parallelize `symlink_hoisted_dependencies` via rayon.** The pass
  now runs in three phases:
  1. Walk the input once to collect every `(target, dest)` pair plus
     the set of `<root>/@scope` dirs needed by scoped aliases.
  2. `create_dir_all` the two hoisted-modules roots and each scope
     dir — once per dir, not per symlink. The previous version
     called `create_dir_all` inside `symlink_package` on every
     symlink, paying ~1k redundant stats on the same handful of
     parents for a typical install.
  3. `pairs.par_iter().try_for_each(...)` issues `symlinkat()`
     syscalls in parallel via the existing rayon thread pool. Each
     pair is now a single syscall, so the only contention is the
     kernel's parent-directory inode lock.

- **Static fast-path for empty pattern lists.** Add
  `Matcher::is_empty()` (returns `true` only for matchers built
  from `&[]`). When *both* compiled matchers are empty,
  `InstallFrozenLockfile::run` short-circuits before building the
  hoist graph, walking the BFS, and entering the symlink phase.
  Previously these would all run and produce empty output.

- **Drop the now-unused `crate::symlink_package` import** from the
  hoist module — the new path uses `pacquet_fs::symlink_dir`
  directly.

Tests: `just ready` 808/808 pass; `cargo doc --workspace --no-deps`
with `RUSTDOCFLAGS="-D warnings"` clean; `just dylint` clean. New
`is_empty_only_for_empty_pattern_list` unit test guards the
short-circuit guard. CLI hoist integration tests (35/35) verify the
parallelized symlink phase produces the same on-disk layout.
Workspace install (#431) landed in #443. Pacquet now
installs every entry in `Lockfile.importers`, not just the root —
hoist needs to follow.

- `InstallFrozenLockfile::run` now passes the full `importers` map
  to `build_direct_deps_by_importer` instead of filtering down to
  the root importer. Transitives unique to a workspace package now
  reach the shared `<vs>/node_modules` private hoist target,
  matching upstream's `directDepsByImporterId` shape.

- New `workspace_hoist_walks_every_importer` integration test
  covers the basic multi-importer hoist: root + `packages/foo`,
  where `foo` is the only importer pulling in
  `@pnpm.e2e/hello-world-js-bin-parent`. Asserts the transitive
  `@pnpm.e2e/hello-world-js-bin` lands at
  `<workspace>/node_modules/.pnpm/node_modules/@pnpm.e2e/hello-world-js-bin`
  even though the root has no deps. Verified by temporarily
  reverting the importers swap — the test fails with the expected
  "transitive of workspace package must be privately hoisted"
  message.

- `known_failures` reasons updated. The original `workspace_install()`
  blanket reason (which pointed at #431) is replaced by three
  more-specific reasons that match the actual blockers:
  - `manifest_mutation_via_pnpm_add()` for tests that call
    `pnpm add`-equivalent flows mid-test
  - `workspace_filter_selection()` for tests that use
    `--filter`-style project selection (separate from #431; not
    yet implemented)
  - `hoist_workspace_packages_unsupported()` for the
    `hoistWorkspacePackages` config which links workspace
    projects themselves (separate `hoistedWorkspacePackages`
    shape pacquet doesn't model yet)

  `workspace_hoist_all_to_virtual_store_node_modules` now points at
  `partial_install_persists_hoisted_map()` since the basic shape
  is covered by the new `workspace_hoist_walks_every_importer`
  test — only the re-install-and-preserve assertion is still gated.

- Lockfile types changed: `ResolvedDependencySpec.version` is now
  `ImporterDepVersion` (`Regular | Link`). `build_direct_deps_by_importer`
  uses `as_regular()` to skip `link:` workspace siblings — they're
  not snapshots and aren't hoist candidates (upstream handles them
  via the separate `hoistedWorkspacePackages` shape).

- `SymlinkDirectDependencies` field cleanup from #443: `requester`
  was removed (now derived per-importer from `rootDir`),
  `workspace_root` was added.

- Updated module docs and `plans/TEST_PORTING.md` to reflect that
  workspace install (#431) landed; remaining workspace-related
  hoist tests are gated on more-specific blockers.

Tests: `just ready` 882/882 pass; `cargo doc --workspace --no-deps`
with `RUSTDOCFLAGS="-D warnings"` clean; `just dylint` clean.
… graph build

Three more hoist optimizations to claw back the remaining ~90ms gap
vs `pacquet@main`. Each one is independent and verified by the
existing test suite (12 unit + 36 CLI hoist tests, 882/882 overall).

- **Borrow children in `BfsEntry`** — was the biggest hotspot.
  Pre-change, every BFS-visited node cloned its full
  `HashMap<String, PackageKey>` of children into the entry list.
  For ~1.5k snapshots × ~3 children avg, that's ~4.5k String +
  PackageKey allocations per install. The BFS now stores
  `&'a HashMap<String, PackageKey>` borrowed from the input graph
  (or the importer's direct-deps map for depth=-1 pseudo-nodes);
  the visited set + work queue also hold `&'a PackageKey`
  borrowed from the graph's keyspace via `get_key_value`.

- **Drop within-entry alias sort** — the inner loop was running
  `child_pairs.sort_by(|a, b| a.0.cmp(b.0))` on every entry's
  children. Children is a `HashMap<alias, _>` so within-entry
  alias collisions are impossible, and the on-disk
  `hoistedDependencies` is `BTreeMap`-sorted at write time.
  The inner sort produced no observable difference and cost
  ~entries × Vec-build + log-fanout sort. Now iterates the
  HashMap directly.

- **Parallelize `build_hoist_graph` via rayon** — single-threaded
  loop over ~1.5k snapshots becomes `par_iter().filter_map(...).collect()`.
  Per-snapshot work (children-map build, package-metadata
  lookup) is independent; rayon fans it across the existing
  thread pool.

`sort_key` on `BfsEntry` stays a `String` (one alloc per node)
because `PackageKey` deliberately doesn't carry an `Ord` impl that
would match the old `to_string()` lex order — switching to
component-wise lex would diverge for scoped names. The single
String allocation per node is cheap relative to what was a
HashMap clone.

Tests: `just ready` 882/882 pass; `cargo doc --workspace --no-deps`
with `RUSTDOCFLAGS="-D warnings"` clean; `just dylint` clean.
CI integrated-benchmark will measure the win on Linux.
Tail-end follow-up to the BFS-borrow + parallel-graph-build perf
work. Targets the symlink phase's per-alias PathBuf cloning, which
duplicates the (already expensive) `to_virtual_store_name()` string
on every hoist entry.

- `symlink_hoisted_dependencies` now collects `(Arc<dep_dir>, kind,
  alias)` tuples instead of `(PathBuf, PathBuf)` pairs. Sharing
  `dep_dir` via `Arc` avoids cloning the PathBuf — and the
  `to_virtual_store_name()` String inside it — once per alias on
  multi-alias nodes. Most nodes have a single hoisted alias, but
  the lockfile crate itself flags `to_virtual_store_name()` as
  "far from optimal" (4× `String::replace` per call), so building
  it just once per node is worth the indirection.

- Scope-dir detection now reads the alias string directly
  (`alias.starts_with('@') && alias.find('/')`) instead of
  materialising the full `dest` PathBuf to look up its parent.
  Saves one PathBuf allocation per unscoped alias in phase 1
  (~90% of the workload on a typical install).

- `dest` construction moves into the parallel closure so phase 1
  doesn't allocate it; phase 3 builds it per task and uses it
  twice (the syscall + the error variant).

Tests: `just ready` 882/882 pass; `cargo doc --workspace --no-deps`
with `RUSTDOCFLAGS="-D warnings"` clean; `just dylint` clean.
Whether this wins back any measurable wall time depends on the
benchmark's noise floor (±60-130ms std-dev on this fixture)
swallowing the trim; CI will tell.
Workspace install (#431 / #443) and GVS install activation (#432 / #449)
both landed since the hoist work in #435 began. The rebase needed two
adjustments to keep the symlink target paths correct under GVS:

- `symlink_hoisted_dependencies` now takes `&VirtualStoreLayout`
  instead of `virtual_store_dir: &Path`. The symlink target (the
  source of each hoist symlink) goes through `layout.slot_dir(key)`,
  which under GVS resolves to
  `<store_dir>/links/<scope>/<name>/<version>/<hash>/` and falls
  back to the legacy `<virtual_store_dir>/<key.virtual_store_name>/`
  flat name when GVS is off. The hoist code never has to branch on
  `enable_global_virtual_store` itself.

- The private hoist *target dir* (where hoist symlinks live) stays
  `config.virtual_store_dir.join("node_modules")`. Pacquet keeps
  `virtual_store_dir` project-local even with GVS enabled — only
  `global_virtual_store_dir` carries the shared `<store_dir>/links`
  path (see `Config::apply_global_virtual_store_derivation`). So
  `<root>/node_modules/.pnpm/node_modules` is still the right
  placement under both modes; the GVS-rewire concern from the
  issue description doesn't apply to pacquet's split-field design.

Updated the call site comment to record this and dropped the stale
"GVS not implemented yet — tracked at #432" note.

Tests: `just ready` 894/894 pass; `cargo doc --workspace --no-deps`
with `RUSTDOCFLAGS="-D warnings"` clean; `just dylint` clean. The
full hoist CLI suite (36/36) and unit suite (12/12) still pass with
the GVS layout pulled into the symlink path.
Address PR #445 review feedback after the rebase pulled in #439
(installability check / `SkippedSnapshots`) + #443 (workspace
install).

- **Honor `SkippedSnapshots` in the hoist pass** (Copilot, real
  bug). `install_frozen_lockfile.rs` was passing
  `HashSet::new()` to `HoistInputs.skipped` even though
  `compute_skipped_snapshots` already produces the
  optional+platform-incompatible skip set earlier in the same
  function. Effect of the fix: hoist no longer creates symlinks
  to slots that were never extracted, and an alias that would
  have been claimed by a skipped snapshot can now be claimed by
  a non-skipped sibling at a deeper level. The `HoistInputs`
  shape stays `&HashSet<PackageKey>` so `hoist.rs` doesn't
  depend on `installability`; the cheap conversion happens at
  the call site.

- **Refresh `build_direct_deps_by_importer` doc comment**
  (Copilot). Doc said "currently only installs the root importer"
  — wrong since #443 (workspace install) and the follow-up that
  switched the call site to pass the full `importers` map.
  Updated to describe the current per-importer walk and the
  `link:` filter inside the loop.

- **Refresh `skipped_optional_deps` known-failure reason**
  (Copilot). The reason claimed pacquet doesn't compute skip
  sets — wrong since #439. The actual gap is the test fixture:
  upstream's `@pnpm.e2e/not-compatible-with-any-os` isn't in
  pacquet's mocked registry, so the end-to-end skip-then-hoist
  path can't be exercised yet. Reason updated to point at the
  fixture gap, not the (already-implemented) skip computation.

Tests: `just ready` 902/902 pass; `cargo doc --workspace --no-deps`
with `RUSTDOCFLAGS="-D warnings"` clean; `just dylint` clean.
The 36 CLI hoist tests + 12 unit tests still pass with the real
skip set threaded through.

Out of scope: Copilot's comment about `satisfies_package_manifest`
only validating the root importer is about main's #447 freshness
check (not this PR's hoist work). Worth a follow-up issue but
not addressed here.
@zkochan zkochan merged commit 000b198 into main May 13, 2026
16 checks passed
@zkochan zkochan deleted the feat/435 branch May 13, 2026 15:53
KSXGitHub pushed a commit that referenced this pull request May 13, 2026
Pull in 4 commits from upstream main:
- feat(lockfile): BinaryResolution + VariationsResolution (#457)
- feat: hoisting support (hoistPattern + publicHoistPattern) (#445)
- test(git-fetcher): port §E git-fetcher tests (#462)
- feat(real-hoist): ancestor-path-aware peer-shadow refusal (#461)

Conflict: `crates/config/src/lib.rs` — the hoisting PR (#445)
added `pub mod matcher;` adjacent to my `mod env_replace;`. Keep
both module declarations.
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add hoisting support: hoistPattern and publicHoistPattern

2 participants