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

feat: workspace support for pacquet install --frozen-lockfile (#431)#443

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

feat: workspace support for pacquet install --frozen-lockfile (#431)#443
zkochan merged 13 commits into
mainfrom
feat/431

Conversation

@zkochan

@zkochan zkochan commented May 13, 2026

Copy link
Copy Markdown
Member

Summary

Stage 1 of pnpm/pnpm#11633 / closes #431 (Stage 1 scope) / partially closes #357.

  • New crate pacquet-workspace — ports pnpm's workspace/root-finder, workspace/workspace-manifest-reader, workspace/projects-reader, and workspace/project-manifest-reader (pinned to 94240bc046). find_workspace_dir honours NPM_CONFIG_WORKSPACE_DIR (and its lowercase spelling, with empty-value fall-through), walks up to pnpm-workspace.yaml, and rejects misnamed variants (pnpm-workspace.yml, .pnpm-workspace.yaml, ...) with BAD_WORKSPACE_MANIFEST_NAME. Project enumeration glob-expands packages: via wax, always includes the workspace root (Always include the root package.json in a workspace pnpm#1986), filters **/node_modules/** + **/bower_components/**, sorts lex by rootDir, and preserves the omitted-vs-explicit-empty distinction so packages: [] enumerates only the root rather than falling back to the recursive ['.', '**'] default.

  • Per-importer install pathSymlinkDirectDependencies iterates every entry in Lockfile.importers, computes per-project rootDir and node_modules/, and uses rootDir as the prefix for pnpm:root added events (matching upstream's linkDirectDeps). Install-wide events (pnpm:stage, pnpm:summary) still use lockfileDir, now resolved through find_workspace_dir. MissingRootImporter is gone — an empty importers map is a silent no-op. A custom modulesDir from pnpm-workspace.yaml propagates to every importer (the symlink stage reads config.modules_dir.file_name() rather than hard-coding node_modules).

  • Path-anchoring consistencyConfig::current re-anchors modules_dir / virtual_store_dir to the workspace root when pnpm-workspace.yaml is found in an ancestor, mirroring pnpm v11's pnpmConfig.dir = lockfileDir. Running pacquet install from a workspace subdirectory no longer produces two inconsistent node_modules layouts (subdir virtual store vs workspace-root per-importer symlinks). Config::current also honours NPM_CONFIG_WORKSPACE_DIR so the env-var override drives both find_workspace_dir and the path anchors. workspace_root is threaded through InstallFrozenLockfile as a real &Path (no lossy-UTF-8 round-trip), so non-UTF-8 filenames survive the install layer intact.

  • Lockfile link: supportResolvedDependencySpec.version is now an ImporterDepVersion enum (Regular(PkgVerPeer) | Link(String)), so a v9 workspace lockfile with version: link:../shared parses. The symlink stage resolves the link target against the importer's rootDir and points node_modules/<name> at the sibling project rather than the virtual store; BuildModules, pacquet-real-hoist, and the side-effects-cache write path all skip Link variants when collecting snapshot-key roots.

  • Importer-key validation — rejects absolute POSIX paths, Windows drive prefixes, backslash separators, .. segments, and the empty string with a typed UnsafeImporterPath error, so a malformed (or hostile) lockfile cannot make Path::join create node_modules outside the workspace root.

  • Symlink error propagation — the prior expect("symlink pkg") is replaced with try_for_each + a SymlinkDirectDependenciesError::SymlinkPackage variant carrying the importer_id, name, and underlying SymlinkPackageError. A filesystem failure no longer crashes the rayon pool.

  • Adds wax = 0.7.0 to [workspace.dependencies] for project globbing.

Test plan

  • just ready — 825 tests pass (2 skipped)
  • taplo format --check — clean
  • just dylint (perfectionist) — clean
  • 25 new tests in pacquet-workspace covering discovery, env-var override (incl. empty-fall-through, lowercase spelling, in-memory injection so no set_var UB), manifest parsing (omitted vs explicit-empty packages:), and project enumeration (overlap dedup, root-always-included, node_modules ignore, custom defaults)
  • New pacquet-config tests pin path anchoring: workspace-subdir re-anchors at workspace root, single-project keeps the cwd, NPM_CONFIG_WORKSPACE_DIR re-anchors, empty env-var falls through (EnvGuard so concurrent tests don't race)
  • pacquet-lockfile round-trips a workspace lockfile with a workspace:*link:../shared dep, and pins ImporterDepVersion::{Regular, Link} parsing
  • pacquet-package-manager covers per-importer pnpm:root prefix, cross-importer link: symlink targets, custom modulesDir propagation, unsafe-importer-key rejection (7 shapes, no filesystem writes), and empty-importers no-op

Out of scope (Stage 2 of pnpm/pnpm#11633)


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

Copilot AI review requested due to automatic review settings May 13, 2026 10:32
@coderabbitai

coderabbitai Bot commented May 13, 2026

Copy link
Copy Markdown

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 a pacquet-workspace crate (workspace manifest parsing, project enumeration, root finding), encodes lockfile importer versions with ImporterDepVersion (Regular vs Link), refactors symlink/bin linking to operate per-importer under a workspace root, and threads workspace-root discovery into install flows.

Changes

Workspace Support and Per-Importer Handling

Layer / File(s) Summary
Top-level Cargo and package-manager manifests
Cargo.toml, crates/package-manager/Cargo.toml, crates/workspace/Cargo.toml
Adds pacquet-workspace as a workspace dependency and wax = "0.7.0" to top-level manifests; adds pacquet-workspace to package-manager manifest.
Lockfile importer version type
crates/lockfile/src/resolved_dependency.rs, crates/lockfile/src/resolved_dependency/tests.rs
ResolvedDependencySpec.version now uses ImporterDepVersion (Regular(PkgVerPeer) / Link(String)), with FromStr, TryFrom, Display, and custom Serialize/Deserialize to preserve link: scalars; tests added for parsing and YAML deserialization.
Lockfile save/round-trip tests
crates/lockfile/src/save_lockfile/tests.rs
Adds v9 lockfile round-trip test ensuring version: link:../shared preserves exact scalar form through save+reparse and Lockfile equality.
Build-sequence and small updates
crates/package-manager/src/build_sequence.rs, crates/package-manager/src/build_modules/tests.rs, crates/package-manager/src/build_sequence/tests.rs
collect_root_dep_paths skips non-regular (link:) importer deps; small test formatting and parsing updates to construct ResolvedDependencySpec using the new version type.
Install flow integration
crates/package-manager/src/install.rs, crates/package-manager/src/install_frozen_lockfile.rs
pacquet install uses find_workspace_dir() to compute reporter prefix (falls back to manifest dir); adds InstallError::FindWorkspaceDir; InstallFrozenLockfile now carries workspace_root used by symlink and build phases.
Per-importer symlink creation
crates/package-manager/src/symlink_direct_dependencies.rs, crates/package-manager/src/symlink_direct_dependencies/tests.rs
Refactors SymlinkDirectDependencies to accept workspace_root, iterate importers deterministically, validate importer ids, resolve link: targets relative to importer roots, dedupe and link each importer's direct deps and bins, emit per-importer pnpm:root added events, and map filesystem errors to typed variants. Tests added/updated for workspace links, multiple importers, empty importers, and unsafe importer ids.
Workspace crate (pacquet-workspace)
crates/workspace/Cargo.toml, crates/workspace/src/lib.rs, crates/workspace/src/manifest.rs, crates/workspace/src/project_manifest.rs, crates/workspace/src/projects.rs, crates/workspace/src/root_finder.rs, crates/workspace/src/*/tests.rs
New pacquet-workspace crate providing find_workspace_dir, read_workspace_manifest, project-manifest readers, and find_workspace_projects with glob expansion, normalization, ignore filters, dedupe, and unit tests covering parsing, discovery, and edge cases.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

Possibly related PRs

  • pnpm/pacquet#333: Related symlink/bin linking changes that intersect with symlink_direct_dependencies work.
  • pnpm/pacquet#372: Related install prefix/requester wiring.
  • pnpm/pacquet#391: Related frozen-lockfile/install pipeline modifications.

Suggested reviewers

  • KSXGitHub

Poem

🐰 I hopped through manifests and traced each workspace trail,
I parsed both semver and links and kept each link's detail.
Per-importer I did bind, each bin and symlink neat,
Now installs sing in order — carrots for everyone to eat! 🥕

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the main feature being added: workspace support for pacquet install with frozen-lockfile, and references the related issue #431.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The PR description is comprehensive, well-structured, and covers all key aspects of the feature implementation including summary, objectives, and test plan.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/431

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

@codecov

codecov Bot commented May 13, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 87.75510% with 54 lines in your changes missing coverage. Please review.
✅ Project coverage is 87.16%. Comparing base (34be005) to head (aa21de7).
⚠️ Report is 2 commits behind head on main.

Files with missing lines Patch % Lines
crates/workspace/src/projects.rs 68.11% 22 Missing ⚠️
...package-manager/src/symlink_direct_dependencies.rs 91.86% 10 Missing ⚠️
crates/config/src/lib.rs 90.21% 9 Missing ⚠️
crates/lockfile/src/resolved_dependency.rs 87.75% 6 Missing ⚠️
crates/workspace/src/manifest.rs 87.50% 3 Missing ⚠️
crates/package-manager/src/build_sequence.rs 66.66% 1 Missing ⚠️
crates/real-hoist/src/lib.rs 66.66% 1 Missing ⚠️
crates/workspace/src/project_manifest.rs 97.29% 1 Missing ⚠️
crates/workspace/src/root_finder.rs 96.87% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     pnpm/pacquet#443      +/-   ##
==========================================
- Coverage   87.20%   87.16%   -0.04%     
==========================================
  Files         105      113       +8     
  Lines        8493     9187     +694     
==========================================
+ Hits         7406     8008     +602     
- Misses       1087     1179      +92     

☔ 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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR introduces Stage 1 workspace support for pacquet install --frozen-lockfile by adding a new pacquet-workspace crate (workspace root discovery + pnpm-workspace.yaml parsing + project enumeration) and updating the installer pipeline to operate per-lockfile-importer (including link: workspace deps) while aligning reporter prefix semantics with upstream pnpm.

Changes:

  • Added pacquet-workspace crate implementing workspace root finding, workspace manifest parsing (packages:), project enumeration, and project manifest reading.
  • Updated frozen-lockfile install to resolve lockfileDir via workspace discovery and to symlink direct dependencies per importer (including version: link:... support).
  • Updated lockfile types to parse/serialize importer dependency version as either a regular semver-with-peer or a link: workspace target; added wax for globbing.

Reviewed changes

Copilot reviewed 22 out of 23 changed files in this pull request and generated 10 comments.

Show a summary per file
File Description
crates/workspace/src/lib.rs Exposes workspace discovery/manifest/projects APIs from the new crate.
crates/workspace/src/root_finder.rs Implements find_workspace_dir parity with pnpm (walk-up + bad-name detection + env override).
crates/workspace/src/root_finder/tests.rs Adds unit tests for workspace root discovery behavior.
crates/workspace/src/manifest.rs Parses pnpm-workspace.yaml into a minimal WorkspaceManifest (currently packages: only).
crates/workspace/src/manifest/tests.rs Tests workspace manifest parsing/validation behaviors.
crates/workspace/src/project_manifest.rs Adds package.json-only project manifest reader.
crates/workspace/src/project_manifest/tests.rs Tests manifest reader behavior for missing/unsupported basenames.
crates/workspace/src/projects.rs Enumerates workspace projects by glob-expanding packages: (via wax) with ignores and sorting.
crates/workspace/src/projects/tests.rs Tests glob expansion, root inclusion, ignores, dedupe, and defaults.
crates/workspace/Cargo.toml Declares the new pacquet-workspace crate and its dependencies.
crates/package-manager/src/install.rs Resolves install prefix via find_workspace_dir to match pnpm’s lockfileDir.
crates/package-manager/src/install_frozen_lockfile.rs Threads workspace root into direct-dependency symlinking stage.
crates/package-manager/src/symlink_direct_dependencies.rs Fans out direct-dep symlinking across all lockfile importers; adds link: handling and per-importer prefix emits.
crates/package-manager/src/symlink_direct_dependencies/tests.rs Adds coverage for link: cross-importer symlinks, per-importer prefixes, and empty-importers no-op.
crates/package-manager/src/build_sequence.rs Skips link: deps when collecting snapshot-root build entries.
crates/package-manager/src/build_sequence/tests.rs Updates tests for new importer dep version type.
crates/package-manager/src/build_modules/tests.rs Updates tests for new importer dep version type.
crates/package-manager/Cargo.toml Adds pacquet-workspace dependency.
crates/lockfile/src/resolved_dependency.rs Introduces ImporterDepVersion (Regular vs Link) for importer-level deps; custom serde + parsing.
crates/lockfile/src/resolved_dependency/tests.rs Adds tests for parsing/serde of ImporterDepVersion and ResolvedDependencySpec.
crates/lockfile/src/save_lockfile/tests.rs Adds workspace lockfile round-trip fixture with a link: dep.
Cargo.toml Registers pacquet-workspace in workspace deps and adds wax = 0.7.0.
Cargo.lock Locks new dependency graph for wax and the new crate.

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

Comment thread crates/package-manager/src/symlink_direct_dependencies.rs
Comment thread crates/package-manager/src/symlink_direct_dependencies.rs Outdated
Comment thread crates/package-manager/src/symlink_direct_dependencies.rs Outdated
Comment thread crates/workspace/src/projects.rs Outdated
Comment thread crates/workspace/src/projects.rs
Comment thread crates/workspace/src/root_finder.rs Outdated
Comment thread crates/workspace/src/project_manifest.rs Outdated
Comment thread crates/workspace/src/manifest.rs
Comment thread crates/package-manager/src/install.rs Outdated
Comment thread crates/package-manager/src/symlink_direct_dependencies/tests.rs Outdated
@github-actions

github-actions Bot commented May 13, 2026

Copy link
Copy Markdown

Micro-Benchmark Results

Linux

group                          main                                   pr
-----                          ----                                   --
tarball/download_dependency    1.00     17.0±0.45ms   255.4 KB/sec    1.00     17.0±0.24ms   254.7 KB/sec

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (3)
crates/lockfile/src/resolved_dependency/tests.rs (1)

10-10: ⚡ Quick win

Add failure context to non-assert_eq! assertions.

These assert! checks are currently opaque on failure; add assertion messages (or nearby logging) so failures are diagnosable without reruns.

Proposed update
-    assert!(parsed.as_link_target().is_none());
+    assert!(
+        parsed.as_link_target().is_none(),
+        "expected regular version to have no link target, got: {parsed:?}"
+    );

-    assert!(matches!(parsed, ImporterDepVersion::Regular(_)));
+    assert!(
+        matches!(parsed, ImporterDepVersion::Regular(_)),
+        "expected Regular variant, got: {parsed:?}"
+    );

-    assert!(parsed.as_regular().is_none());
+    assert!(
+        parsed.as_regular().is_none(),
+        "expected link version to have no regular value, got: {parsed:?}"
+    );

-    assert!(spec.version.as_regular().is_some());
+    assert!(
+        spec.version.as_regular().is_some(),
+        "expected regular importer version, got: {:?}",
+        spec.version
+    );

Based on learnings: In Rust test code, add logging/context for assert!/assert_ne! failures unless using assert_eq!.

Also applies to: 19-19, 28-28, 56-56

🤖 Prompt for 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.

In `@crates/lockfile/src/resolved_dependency/tests.rs` at line 10, Replace opaque
assert! checks in the tests with assertions that include failure context: for
each occurrence (e.g. the expression parsed.as_link_target().is_none(), other
assert! uses at the mentioned positions) change to an assertion that prints the
actual value on failure—either use assert_eq!(None, parsed.as_link_target(),
"parsed.as_link_target() should be None but was: {:?}", parsed.as_link_target())
or add a message to assert!(parsed.as_link_target().is_none(), "expected no link
target, got: {:?}", parsed.as_link_target()); apply the same pattern to the
other assert! / assert_ne! occurrences noted so failures show the offending
value.
crates/package-manager/src/symlink_direct_dependencies/tests.rs (1)

282-288: ⚡ Quick win

Assert the symlink destination, not just that it exists.

This check can pass even if the link points to the wrong directory. Add a canonicalized target assertion to pin the “sibling rootDir (not virtual store)” contract.

Proposed update
     let symlink_path = workspace_root.join("packages/web/node_modules/shared");
     assert!(
         is_symlink_or_junction(&symlink_path).unwrap(),
         "expected a symlink at {symlink_path:?}",
     );
+    let actual_target = fs::canonicalize(&symlink_path).expect("resolve symlink target");
+    let expected_target = fs::canonicalize(&shared_dir).expect("resolve shared project dir");
+    assert_eq!(
+        actual_target, expected_target,
+        "workspace link should target sibling project rootDir"
+    );
🤖 Prompt for 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.

In `@crates/package-manager/src/symlink_direct_dependencies/tests.rs` around lines
282 - 288, The test currently only checks that symlink_path exists as a symlink;
update it to also verify the symlink target is the sibling package rootDir: use
std::fs::read_link on symlink_path (or equivalent helper) and canonicalize that
result, canonicalize the expected sibling root (e.g.,
workspace_root.join("packages/shared") or the variable representing the sibling
rootDir), and assert they are equal so the link points at the sibling's rootDir
(not the virtual store); keep the existing is_symlink_or_junction check and add
this canonicalized-target assertion.
crates/workspace/src/root_finder/tests.rs (1)

10-67: ⚡ Quick win

Add a focused test for NPM_CONFIG_WORKSPACE_DIR override behavior.

Given this PR’s new root-finding behavior, a dedicated test for env-var precedence/fallback would harden against regressions in Stage 2 follow-ups.

🤖 Prompt for 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.

In `@crates/workspace/src/root_finder/tests.rs` around lines 10 - 67, Add a
focused unit test in crates/workspace/src/root_finder/tests.rs that verifies the
new environment-variable precedence: set NPM_CONFIG_WORKSPACE_DIR to a path (use
TempDir) and assert find_workspace_dir returns that dir even if
pnpm-workspace.yaml exists elsewhere; also add sub-cases for an
invalid/nonexistent NPM_CONFIG_WORKSPACE_DIR value to ensure find_workspace_dir
falls back to normal discovery (e.g., finds ancestor manifest or returns None)
and for an empty/unset variable; reference the find_workspace_dir function and
the NPM_CONFIG_WORKSPACE_DIR symbol so the test explicitly exercises the env-var
override and its fallback behavior.
🤖 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/package-manager/src/install.rs`:
- Around line 110-111: The current assignment to the variable `prefix` uses
`workspace_root.to_str().expect(...)` which panics on non-UTF-8 paths; change
the logic that sets `prefix` (the `prefix` binding in install.rs) to use a
lossless conversion such as `to_string_lossy().into_owned()` so non-UTF-8
workspace paths don't cause a panic and logging still receives a usable string.

In `@crates/package-manager/src/symlink_direct_dependencies.rs`:
- Line 211: The code currently calls symlink_package(&target_path,
&modules_dir.join(&name_str)).expect("symlink pkg") inside a par_iter which will
panic on failure; change this to propagate errors instead: have the closure
return a Result for each item (e.g., Result<(), Error>) rather than calling
expect, call symlink_package(...) ? to propagate its error, and then collect or
try_for_each over the par_iter results to aggregate/early-return a single Result
from the parent function; reference symlink_package,
modules_dir.join(&name_str), and the par_iter closure so you replace the expect
panic with proper Result propagation (or send errors through a shared channel
and abort early) and update the parent function signature to return a Result if
needed.

---

Nitpick comments:
In `@crates/lockfile/src/resolved_dependency/tests.rs`:
- Line 10: Replace opaque assert! checks in the tests with assertions that
include failure context: for each occurrence (e.g. the expression
parsed.as_link_target().is_none(), other assert! uses at the mentioned
positions) change to an assertion that prints the actual value on failure—either
use assert_eq!(None, parsed.as_link_target(), "parsed.as_link_target() should be
None but was: {:?}", parsed.as_link_target()) or add a message to
assert!(parsed.as_link_target().is_none(), "expected no link target, got: {:?}",
parsed.as_link_target()); apply the same pattern to the other assert! /
assert_ne! occurrences noted so failures show the offending value.

In `@crates/package-manager/src/symlink_direct_dependencies/tests.rs`:
- Around line 282-288: The test currently only checks that symlink_path exists
as a symlink; update it to also verify the symlink target is the sibling package
rootDir: use std::fs::read_link on symlink_path (or equivalent helper) and
canonicalize that result, canonicalize the expected sibling root (e.g.,
workspace_root.join("packages/shared") or the variable representing the sibling
rootDir), and assert they are equal so the link points at the sibling's rootDir
(not the virtual store); keep the existing is_symlink_or_junction check and add
this canonicalized-target assertion.

In `@crates/workspace/src/root_finder/tests.rs`:
- Around line 10-67: Add a focused unit test in
crates/workspace/src/root_finder/tests.rs that verifies the new
environment-variable precedence: set NPM_CONFIG_WORKSPACE_DIR to a path (use
TempDir) and assert find_workspace_dir returns that dir even if
pnpm-workspace.yaml exists elsewhere; also add sub-cases for an
invalid/nonexistent NPM_CONFIG_WORKSPACE_DIR value to ensure find_workspace_dir
falls back to normal discovery (e.g., finds ancestor manifest or returns None)
and for an empty/unset variable; reference the find_workspace_dir function and
the NPM_CONFIG_WORKSPACE_DIR symbol so the test explicitly exercises the env-var
override and its fallback behavior.
🪄 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: 7bb2bc9b-8bc1-4078-87cd-5414295c9633

📥 Commits

Reviewing files that changed from the base of the PR and between b514929 and 2401412.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (22)
  • Cargo.toml
  • crates/lockfile/src/resolved_dependency.rs
  • crates/lockfile/src/resolved_dependency/tests.rs
  • crates/lockfile/src/save_lockfile/tests.rs
  • crates/package-manager/Cargo.toml
  • crates/package-manager/src/build_modules/tests.rs
  • crates/package-manager/src/build_sequence.rs
  • crates/package-manager/src/build_sequence/tests.rs
  • crates/package-manager/src/install.rs
  • crates/package-manager/src/install_frozen_lockfile.rs
  • crates/package-manager/src/symlink_direct_dependencies.rs
  • crates/package-manager/src/symlink_direct_dependencies/tests.rs
  • crates/workspace/Cargo.toml
  • crates/workspace/src/lib.rs
  • crates/workspace/src/manifest.rs
  • crates/workspace/src/manifest/tests.rs
  • crates/workspace/src/project_manifest.rs
  • crates/workspace/src/project_manifest/tests.rs
  • crates/workspace/src/projects.rs
  • crates/workspace/src/projects/tests.rs
  • crates/workspace/src/root_finder.rs
  • crates/workspace/src/root_finder/tests.rs
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (8)
  • GitHub Check: Agent
  • GitHub Check: Lint and Test (macos-latest)
  • GitHub Check: Lint and Test (windows-latest)
  • GitHub Check: Dylint
  • GitHub Check: Lint and Test (ubuntu-latest)
  • GitHub Check: Run benchmark on ubuntu-latest
  • GitHub Check: Run benchmark on ubuntu-latest
  • GitHub Check: Code Coverage
🧰 Additional context used
📓 Path-based instructions (1)
**/*.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/build_modules/tests.rs
  • crates/package-manager/src/build_sequence.rs
  • crates/package-manager/src/build_sequence/tests.rs
  • crates/lockfile/src/save_lockfile/tests.rs
  • crates/package-manager/src/install.rs
  • crates/workspace/src/manifest/tests.rs
  • crates/lockfile/src/resolved_dependency/tests.rs
  • crates/workspace/src/lib.rs
  • crates/workspace/src/manifest.rs
  • crates/workspace/src/root_finder.rs
  • crates/package-manager/src/install_frozen_lockfile.rs
  • crates/workspace/src/project_manifest/tests.rs
  • crates/package-manager/src/symlink_direct_dependencies.rs
  • crates/workspace/src/project_manifest.rs
  • crates/workspace/src/projects/tests.rs
  • crates/lockfile/src/resolved_dependency.rs
  • crates/workspace/src/root_finder/tests.rs
  • crates/package-manager/src/symlink_direct_dependencies/tests.rs
  • crates/workspace/src/projects.rs
🧠 Learnings (2)
📚 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/build_modules/tests.rs
  • crates/package-manager/src/build_sequence/tests.rs
  • crates/lockfile/src/save_lockfile/tests.rs
  • crates/workspace/src/manifest/tests.rs
  • crates/lockfile/src/resolved_dependency/tests.rs
  • crates/workspace/src/project_manifest/tests.rs
  • crates/workspace/src/projects/tests.rs
  • crates/workspace/src/root_finder/tests.rs
  • crates/package-manager/src/symlink_direct_dependencies/tests.rs
📚 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/build_modules/tests.rs
  • crates/package-manager/src/build_sequence.rs
  • crates/package-manager/src/build_sequence/tests.rs
  • crates/lockfile/src/save_lockfile/tests.rs
  • crates/package-manager/src/install.rs
  • crates/workspace/src/manifest/tests.rs
  • crates/lockfile/src/resolved_dependency/tests.rs
  • crates/workspace/src/lib.rs
  • crates/workspace/src/manifest.rs
  • crates/workspace/src/root_finder.rs
  • crates/package-manager/src/install_frozen_lockfile.rs
  • crates/workspace/src/project_manifest/tests.rs
  • crates/package-manager/src/symlink_direct_dependencies.rs
  • crates/workspace/src/project_manifest.rs
  • crates/workspace/src/projects/tests.rs
  • crates/lockfile/src/resolved_dependency.rs
  • crates/workspace/src/root_finder/tests.rs
  • crates/package-manager/src/symlink_direct_dependencies/tests.rs
  • crates/workspace/src/projects.rs
🔇 Additional comments (23)
crates/lockfile/src/resolved_dependency.rs (1)

1-165: LGTM!

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

252-255: LGTM!

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

45-48: LGTM!

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

141-150: LGTM!

crates/lockfile/src/save_lockfile/tests.rs (1)

87-162: LGTM!

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

18-22: LGTM!

Also applies to: 47-54, 153-161

crates/package-manager/src/symlink_direct_dependencies.rs (6)

1-17: LGTM!


19-53: LGTM!


55-60: LGTM!


62-101: LGTM!


103-118: LGTM!


262-274: LGTM!

crates/workspace/src/manifest.rs (1)

1-136: LGTM!

crates/workspace/src/root_finder.rs (1)

1-137: LGTM!

crates/workspace/src/project_manifest.rs (1)

1-107: LGTM!

crates/workspace/src/project_manifest/tests.rs (1)

1-68: LGTM!

crates/workspace/src/projects.rs (1)

1-230: LGTM!

crates/workspace/src/projects/tests.rs (1)

1-118: LGTM!

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

1-38: LGTM!

crates/workspace/src/manifest/tests.rs (1)

1-66: LGTM!

crates/package-manager/Cargo.toml (1)

28-28: LGTM!

Cargo.toml (1)

34-34: LGTM!

Also applies to: 89-89

crates/workspace/Cargo.toml (1)

1-24: LGTM!

Comment thread crates/package-manager/src/install.rs Outdated
Comment thread crates/package-manager/src/symlink_direct_dependencies.rs Outdated
@github-actions

github-actions Bot commented May 13, 2026

Copy link
Copy Markdown

Integrated-Benchmark Report (Linux)

Scenario: Frozen Lockfile

Command Mean [s] Min [s] Max [s] Relative
pacquet@HEAD 2.483 ± 0.042 2.437 2.559 1.00 ± 0.03
pacquet@main 2.476 ± 0.073 2.357 2.578 1.00
pnpm 5.939 ± 0.106 5.809 6.122 2.40 ± 0.08
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 2.4831201633599997,
      "stddev": 0.04167633079208864,
      "median": 2.4689126526600003,
      "user": 2.60659114,
      "system": 3.4385569399999993,
      "min": 2.43738637116,
      "max": 2.55940021516,
      "times": [
        2.45252438316,
        2.49372909516,
        2.55940021516,
        2.51041505516,
        2.4588666371600003,
        2.47470540216,
        2.46311990316,
        2.53931036416,
        2.43738637116,
        2.44174420716
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 2.4764405917600003,
      "stddev": 0.07324214662777033,
      "median": 2.4670707701600003,
      "user": 2.59762084,
      "system": 3.4258058399999998,
      "min": 2.3571206081600002,
      "max": 2.57800792016,
      "times": [
        2.57800792016,
        2.47191327716,
        2.40613471616,
        2.40923732116,
        2.5502315121600003,
        2.3571206081600002,
        2.52968021516,
        2.45164480916,
        2.46222826316,
        2.5482072751600002
      ]
    },
    {
      "command": "pnpm",
      "mean": 5.93899265436,
      "stddev": 0.10579744400951284,
      "median": 5.92203868116,
      "user": 8.66512554,
      "system": 4.30418624,
      "min": 5.80863392416,
      "max": 6.12215791416,
      "times": [
        6.0526980331599995,
        6.01267010616,
        5.885087728159999,
        6.12215791416,
        5.870042124159999,
        5.817394129159999,
        5.99771457716,
        5.80863392416,
        5.95898963416,
        5.864538373159999
      ]
    }
  ]
}

Scenario: Frozen Lockfile (Hot Cache)

Command Mean [ms] Min [ms] Max [ms] Relative
pacquet@HEAD 687.9 ± 27.5 667.4 754.7 1.00
pacquet@main 799.5 ± 93.3 662.2 953.1 1.16 ± 0.14
pnpm 2408.6 ± 86.0 2327.9 2633.8 3.50 ± 0.19
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 0.68791756452,
      "stddev": 0.02754256367741308,
      "median": 0.6740233957199999,
      "user": 0.3590085,
      "system": 1.5118548200000004,
      "min": 0.66735246222,
      "max": 0.75472353422,
      "times": [
        0.75472353422,
        0.67264191422,
        0.6720331442199999,
        0.67224584522,
        0.67540487722,
        0.70329413422,
        0.70916553622,
        0.66948583322,
        0.68282836422,
        0.66735246222
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 0.7994735266199999,
      "stddev": 0.09329072902994748,
      "median": 0.78081436322,
      "user": 0.3531575,
      "system": 1.5150295200000001,
      "min": 0.66224441522,
      "max": 0.95305915122,
      "times": [
        0.94752299122,
        0.74549254422,
        0.83707888022,
        0.7672540772199999,
        0.76379389222,
        0.66224441522,
        0.95305915122,
        0.79437464922,
        0.80944026422,
        0.7144744012199999
      ]
    },
    {
      "command": "pnpm",
      "mean": 2.40861705902,
      "stddev": 0.08604618885942437,
      "median": 2.38809026472,
      "user": 2.8847140999999996,
      "system": 2.1824650199999995,
      "min": 2.32789497722,
      "max": 2.63378423722,
      "times": [
        2.63378423722,
        2.34025797422,
        2.3659717002200003,
        2.4274157502200002,
        2.32789497722,
        2.37841632222,
        2.3732966822200003,
        2.4190129712200004,
        2.42235576822,
        2.3977642072200003
      ]
    }
  ]
}

zkochan added a commit that referenced this pull request May 13, 2026
Doc job (blocking):
- Replace private intra-doc links to module names in
  `crates/workspace/src/lib.rs` with text + linked public items.
- Drop two `[`pacquet_config::WorkspaceSettings`]` rustdoc links in
  `crates/workspace/src/manifest.rs` — `pacquet-config` is not a
  dependency of `pacquet-workspace`, so the links were broken.

Review feedback:
- `install.rs:111` — replace `to_str().expect(...)` with
  `to_string_lossy().into_owned()` so non-UTF-8 workspace paths do
  not panic the installer.
- `symlink_direct_dependencies.rs` —
  - Validate every importer key (`validate_importer_id`) and reject
    absolute paths, Windows drive prefixes, backslash separators, and
    `..` segments with `SymlinkDirectDependenciesError::UnsafeImporterPath`.
    A malformed lockfile would otherwise let `Path::join` create
    `node_modules` outside the workspace root.
  - Replace `symlink_package(...).expect("symlink pkg")` with a
    fallible `try_for_each` path and a new
    `SymlinkDirectDependenciesError::SymlinkPackage` variant so a per-
    package symlink failure surfaces as a structured error instead of
    panicking the rayon worker.
  - Fix the struct-level doc comment to refer to
    `<workspace_root>/node_modules/.pacquet` (pacquet's actual virtual
    store dir) rather than `.pnpm`.
- `projects.rs:186` — restrict the manifest-read swallow to
  `ENOENT`-equivalent errors (`Io(NotFound)` and
  `NoImporterManifestFound`); parse / permission / other I/O errors
  now propagate via `FindWorkspaceProjectsError::ReadManifest`.
- `root_finder.rs` — treat an empty `NPM_CONFIG_WORKSPACE_DIR` as
  unset so the upward walk takes over, matching upstream's truthy
  `if (workspaceDir)` check.
- `project_manifest.rs` — drop the misleading mention of
  `package.yaml` / `package.json5` from `NoImporterManifestFound`;
  pacquet only probes `package.json` today.
- `manifest.rs` — remove the unreachable
  `InvalidWorkspaceManifestError::PackagesNotArray` variant; serde
  already rejects non-array shapes at parse time.
- `symlink_direct_dependencies/tests.rs` — drop leftover `dbg!`.

New tests:
- `pacquet-package-manager`: `unsafe_importer_keys_error_before_filesystem_writes`
  covers six unsafe importer-key shapes.
- `pacquet-workspace`: `empty_env_var_is_treated_as_unset` covers
  the `NPM_CONFIG_WORKSPACE_DIR=""` fallback.

Verified locally: `just ready` (649 passed), `taplo format --check`,
`just dylint`, and `RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --workspace --document-private-items`
all clean.

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (2)
crates/workspace/src/manifest.rs (1)

4-4: ⚡ Quick win

Use pnpm blob/main links instead of pinned SHA permalinks in module docs.

These references will drift from the source-of-truth guidance for ports; switch both links to https://github.com/pnpm/pnpm/blob/main/....

Suggested doc-link update
-//! [`readWorkspaceManifest`](https://github.com/pnpm/pnpm/blob/94240bc046/workspace/workspace-manifest-reader/src/index.ts).
+//! [`readWorkspaceManifest`](https://github.com/pnpm/pnpm/blob/main/workspace/workspace-manifest-reader/src/index.ts).
...
-//! [`validateWorkspaceManifest`](https://github.com/pnpm/pnpm/blob/94240bc046/workspace/workspace-manifest-reader/src/index.ts):
+//! [`validateWorkspaceManifest`](https://github.com/pnpm/pnpm/blob/main/workspace/workspace-manifest-reader/src/index.ts):

As per coding guidelines: "When porting features... open files from https://github.com/pnpm/pnpm/blob/main/... rather than from a permalinked SHA."

Also applies to: 16-16

🤖 Prompt for 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.

In `@crates/workspace/src/manifest.rs` at line 4, The crate doc reference to the
PNPM source uses a permalinked SHA; update the module-level doc links that
reference readWorkspaceManifest (and the second occurrence at line 16) to use
the canonical blob/main path (https://github.com/pnpm/pnpm/blob/main/...)
instead of the pinned SHA permalink so both references point to the live main
branch.
crates/package-manager/src/symlink_direct_dependencies/tests.rs (1)

222-228: ⚡ Quick win

Use pnpm blob/main URLs in porting references.

These comments pin upstream links to a specific SHA. For pnpm-porting references in this repo, switch to https://github.com/pnpm/pnpm/blob/main/... so the source-of-truth stays current.

As per coding guidelines, "If reading on GitHub, open files from https://github.com/pnpm/pnpm/blob/main/... ... rather than from a permalinked SHA."

Also applies to: 343-347

🤖 Prompt for 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.

In `@crates/package-manager/src/symlink_direct_dependencies/tests.rs` around lines
222 - 228, The comment currently references an upstream pnpm link with a
permalined SHA (the URL in the block comment that points to the specific
commit/sha); update that URL to use the canonical blob/main path
(https://github.com/pnpm/pnpm/blob/main/...) so the reference tracks the main
branch, and make the same replacement for the other occurrence noted around the
comment block (the similar link mentioned at lines ~343-347) so both upstream
links use blob/main.
🤖 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/package-manager/src/symlink_direct_dependencies/tests.rs`:
- Around line 434-481: The test currently only asserts the error but must also
assert no filesystem writes; after verifying the result is
Err(SymlinkDirectDependenciesError::UnsafeImporterPath {..}) in
unsafe_importer_keys_error_before_filesystem_writes, add an assertion that the
modules directory was not created (e.g.,
assert!(!workspace_root.join("node_modules").exists()) or
assert!(!config.modules_dir.exists())) to ensure no node_modules was written for
each importer_id before dropping dir.

---

Nitpick comments:
In `@crates/package-manager/src/symlink_direct_dependencies/tests.rs`:
- Around line 222-228: The comment currently references an upstream pnpm link
with a permalined SHA (the URL in the block comment that points to the specific
commit/sha); update that URL to use the canonical blob/main path
(https://github.com/pnpm/pnpm/blob/main/...) so the reference tracks the main
branch, and make the same replacement for the other occurrence noted around the
comment block (the similar link mentioned at lines ~343-347) so both upstream
links use blob/main.

In `@crates/workspace/src/manifest.rs`:
- Line 4: The crate doc reference to the PNPM source uses a permalinked SHA;
update the module-level doc links that reference readWorkspaceManifest (and the
second occurrence at line 16) to use the canonical blob/main path
(https://github.com/pnpm/pnpm/blob/main/...) instead of the pinned SHA permalink
so both references point to the live main branch.
🪄 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: 5d697e62-010f-4069-a2f5-1ecafa305a5c

📥 Commits

Reviewing files that changed from the base of the PR and between 2401412 and a014468.

📒 Files selected for processing (9)
  • crates/package-manager/src/install.rs
  • crates/package-manager/src/symlink_direct_dependencies.rs
  • crates/package-manager/src/symlink_direct_dependencies/tests.rs
  • crates/workspace/src/lib.rs
  • crates/workspace/src/manifest.rs
  • crates/workspace/src/project_manifest.rs
  • crates/workspace/src/projects.rs
  • crates/workspace/src/root_finder.rs
  • crates/workspace/src/root_finder/tests.rs
🚧 Files skipped from review as they are similar to previous changes (5)
  • crates/workspace/src/lib.rs
  • crates/workspace/src/projects.rs
  • crates/workspace/src/project_manifest.rs
  • crates/package-manager/src/symlink_direct_dependencies.rs
  • crates/workspace/src/root_finder.rs
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (5)
  • GitHub Check: Lint and Test (windows-latest)
  • GitHub Check: Lint and Test (ubuntu-latest)
  • GitHub Check: Run benchmark on ubuntu-latest
  • GitHub Check: Run benchmark on ubuntu-latest
  • GitHub Check: Code Coverage
🧰 Additional context used
📓 Path-based instructions (1)
**/*.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/install.rs
  • crates/workspace/src/manifest.rs
  • crates/workspace/src/root_finder/tests.rs
  • crates/package-manager/src/symlink_direct_dependencies/tests.rs
🧠 Learnings (2)
📚 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/install.rs
  • crates/workspace/src/manifest.rs
  • crates/workspace/src/root_finder/tests.rs
  • crates/package-manager/src/symlink_direct_dependencies/tests.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/workspace/src/root_finder/tests.rs
  • crates/package-manager/src/symlink_direct_dependencies/tests.rs
🔇 Additional comments (4)
crates/workspace/src/manifest.rs (1)

49-57: LGTM!

Also applies to: 68-94, 101-136

crates/workspace/src/root_finder/tests.rs (1)

1-95: LGTM!

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

1-1: LGTM!

Also applies to: 62-65, 99-117

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

9-200: LGTM!

Also applies to: 230-341, 348-433

Comment thread crates/package-manager/src/symlink_direct_dependencies/tests.rs
zkochan added a commit that referenced this pull request May 13, 2026
… keys

Strengthens `unsafe_importer_keys_error_before_filesystem_writes` to
match its name: in addition to the error-type check, it now asserts
that no `node_modules` directory is created under `workspace_root`
*or* its parent for each rejected importer key. The parent-side check
guards against a regression where `Path::join` would let a `..` or
absolute key land outside the workspace root.

Addresses CodeRabbit feedback on PR #443.

---
Written by an agent (Claude Code, claude-opus-4-7).
Copilot AI review requested due to automatic review settings May 13, 2026 11:31

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

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 22 out of 23 changed files in this pull request and generated 3 comments.

Comment thread crates/package-manager/src/install_frozen_lockfile.rs Outdated
Comment thread crates/package-manager/src/symlink_direct_dependencies.rs
Comment thread crates/workspace/src/root_finder/tests.rs Outdated
zkochan added a commit that referenced this pull request May 13, 2026
…ring round-trip

`Install::run` converts the resolved workspace root to a lossy-UTF-8
string for reporter envelopes (`prefix`) and passes that to
`InstallFrozenLockfile::requester`. The previous code then reconstructed
`PathBuf::from(requester)` inside `InstallFrozenLockfile::run` to feed
`SymlinkDirectDependencies` and `BuildModules` — meaning real filesystem
operations were driven by a path that had already passed through a
lossy conversion. On hosts with non-UTF-8 filenames, the on-disk path
could silently diverge from the workspace root pacquet meant to install
under.

Add a `workspace_root: &'a Path` field on `InstallFrozenLockfile`
threaded straight from `Install::run` (which still holds the original
`PathBuf` returned by `find_workspace_dir`). The lossy `requester`
string remains for reporter envelopes only; filesystem-effecting code
paths (`SymlinkDirectDependencies::workspace_root`, `BuildModules`'s
`lockfile_dir`) now read the real path directly.

Caught by Copilot in PR #443 review of crates/package-manager/src/install_frozen_lockfile.rs:158.

---
Written by an agent (Claude Code, claude-opus-4-7).
Copilot AI review requested due to automatic review settings May 13, 2026 11:53
zkochan added a commit that referenced this pull request May 13, 2026
Doc job (blocking):
- Replace private intra-doc links to module names in
  `crates/workspace/src/lib.rs` with text + linked public items.
- Drop two `[`pacquet_config::WorkspaceSettings`]` rustdoc links in
  `crates/workspace/src/manifest.rs` — `pacquet-config` is not a
  dependency of `pacquet-workspace`, so the links were broken.

Review feedback:
- `install.rs:111` — replace `to_str().expect(...)` with
  `to_string_lossy().into_owned()` so non-UTF-8 workspace paths do
  not panic the installer.
- `symlink_direct_dependencies.rs` —
  - Validate every importer key (`validate_importer_id`) and reject
    absolute paths, Windows drive prefixes, backslash separators, and
    `..` segments with `SymlinkDirectDependenciesError::UnsafeImporterPath`.
    A malformed lockfile would otherwise let `Path::join` create
    `node_modules` outside the workspace root.
  - Replace `symlink_package(...).expect("symlink pkg")` with a
    fallible `try_for_each` path and a new
    `SymlinkDirectDependenciesError::SymlinkPackage` variant so a per-
    package symlink failure surfaces as a structured error instead of
    panicking the rayon worker.
  - Fix the struct-level doc comment to refer to
    `<workspace_root>/node_modules/.pacquet` (pacquet's actual virtual
    store dir) rather than `.pnpm`.
- `projects.rs:186` — restrict the manifest-read swallow to
  `ENOENT`-equivalent errors (`Io(NotFound)` and
  `NoImporterManifestFound`); parse / permission / other I/O errors
  now propagate via `FindWorkspaceProjectsError::ReadManifest`.
- `root_finder.rs` — treat an empty `NPM_CONFIG_WORKSPACE_DIR` as
  unset so the upward walk takes over, matching upstream's truthy
  `if (workspaceDir)` check.
- `project_manifest.rs` — drop the misleading mention of
  `package.yaml` / `package.json5` from `NoImporterManifestFound`;
  pacquet only probes `package.json` today.
- `manifest.rs` — remove the unreachable
  `InvalidWorkspaceManifestError::PackagesNotArray` variant; serde
  already rejects non-array shapes at parse time.
- `symlink_direct_dependencies/tests.rs` — drop leftover `dbg!`.

New tests:
- `pacquet-package-manager`: `unsafe_importer_keys_error_before_filesystem_writes`
  covers six unsafe importer-key shapes.
- `pacquet-workspace`: `empty_env_var_is_treated_as_unset` covers
  the `NPM_CONFIG_WORKSPACE_DIR=""` fallback.

Verified locally: `just ready` (649 passed), `taplo format --check`,
`just dylint`, and `RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --workspace --document-private-items`
all clean.

---
Written by an agent (Claude Code, claude-opus-4-7).
zkochan added a commit that referenced this pull request May 13, 2026
… keys

Strengthens `unsafe_importer_keys_error_before_filesystem_writes` to
match its name: in addition to the error-type check, it now asserts
that no `node_modules` directory is created under `workspace_root`
*or* its parent for each rejected importer key. The parent-side check
guards against a regression where `Path::join` would let a `..` or
absolute key land outside the workspace root.

Addresses CodeRabbit feedback on PR #443.

---
Written by an agent (Claude Code, claude-opus-4-7).
zkochan added a commit that referenced this pull request May 13, 2026
…ring round-trip

`Install::run` converts the resolved workspace root to a lossy-UTF-8
string for reporter envelopes (`prefix`) and passes that to
`InstallFrozenLockfile::requester`. The previous code then reconstructed
`PathBuf::from(requester)` inside `InstallFrozenLockfile::run` to feed
`SymlinkDirectDependencies` and `BuildModules` — meaning real filesystem
operations were driven by a path that had already passed through a
lossy conversion. On hosts with non-UTF-8 filenames, the on-disk path
could silently diverge from the workspace root pacquet meant to install
under.

Add a `workspace_root: &'a Path` field on `InstallFrozenLockfile`
threaded straight from `Install::run` (which still holds the original
`PathBuf` returned by `find_workspace_dir`). The lossy `requester`
string remains for reporter envelopes only; filesystem-effecting code
paths (`SymlinkDirectDependencies::workspace_root`, `BuildModules`'s
`lockfile_dir`) now read the real path directly.

Caught by Copilot in PR #443 review of crates/package-manager/src/install_frozen_lockfile.rs:158.

---
Written by an agent (Claude Code, claude-opus-4-7).
zkochan added a commit that referenced this pull request May 13, 2026
Two items from the 2026-05-13 Copilot review:

1. `validate_importer_id` previously accepted an empty importer key as
   equivalent to `"."`. Pnpm only ever writes `.` for the root importer;
   an empty-string key is non-standard and could mask a malformed
   lockfile. Reject it with `UnsafeImporterPath` instead. The
   `importer_root_dir` lookup is correspondingly tightened to the
   single canonical root key; the empty-key fallback is unreachable
   after validation. New test case (empty string) added to
   `unsafe_importer_keys_error_before_filesystem_writes`.

2. `empty_env_var_is_treated_as_unset` was calling `unsafe { env::set_var }`,
   which is documented UB when other threads access the process
   environment concurrently — and Rust's default test runner is
   multi-threaded. Refactor `find_workspace_dir_from_env` to accept an
   injected env accessor (`find_workspace_dir_from_env_with`) and have
   the production path call it with `std::env::var_os`. Tests now
   exercise the fall-through, truthy-value, and lowercase-fallback
   branches via pure in-memory closures with no `set_var` calls.

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (3)
crates/workspace/src/project_manifest/tests.rs (1)

23-32: ⚡ Quick win

Add diagnostic logging before non-assert_eq! assertions.

Line 25 and Line 31 use assert!(...is_none()) without logging the evaluated result, which makes failures harder to diagnose in CI output.

Proposed patch
 #[test]
 fn try_read_returns_none_when_missing() {
     let tmp = TempDir::new().unwrap();
-    assert!(try_read_project_manifest(tmp.path()).unwrap().is_none());
+    let result = try_read_project_manifest(tmp.path()).unwrap();
+    eprintln!("try_read_project_manifest({:?}) => {:?}", tmp.path(), result);
+    assert!(result.is_none());
 }
 
 #[test]
 fn safe_read_returns_none_when_missing() {
     let tmp = TempDir::new().unwrap();
-    assert!(safe_read_project_manifest_only(tmp.path()).unwrap().is_none());
+    let result = safe_read_project_manifest_only(tmp.path()).unwrap();
+    eprintln!(
+        "safe_read_project_manifest_only({:?}) => {:?}",
+        tmp.path(),
+        result
+    );
+    assert!(result.is_none());
 }

Based on learnings: In Rust test code, add logging around non-assert_eq! assertions so failures are diagnosable without rerunning.

🤖 Prompt for 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.

In `@crates/workspace/src/project_manifest/tests.rs` around lines 23 - 32, Capture
the result of try_read_project_manifest(tmp.path()) and
safe_read_project_manifest_only(tmp.path()) into a local variable inside the
tests try_read_returns_none_when_missing and
safe_read_returns_none_when_missing, emit diagnostic output (e.g., with
eprintln! or dbg!) showing the evaluated value, then perform the assertion
(prefer assert_eq!(res, None) or assert!(res.is_none())). This ensures you log
the computed value from try_read_project_manifest and
safe_read_project_manifest_only before asserting so failures are diagnosable in
CI.
crates/workspace/src/projects/tests.rs (1)

56-77: ⚡ Quick win

Add bower_components exclusion coverage in this filter test.

Given this stage’s behavior includes filtering both node_modules and bower_components, adding a sibling assertion here would better protect against regressions.

Suggested patch
 fn filters_node_modules() {
     let tmp = TempDir::new().unwrap();
     make_project(tmp.path(), ".", "root");
     make_project(tmp.path(), "node_modules/foo", "foo");
+    make_project(tmp.path(), "bower_components/bar", "bar");
     make_project(tmp.path(), "packages/real", "real");
@@
     assert!(
         !names.contains(&"foo".to_string()),
         "node_modules contents must not surface as workspace projects: {names:?}",
     );
+    assert!(
+        !names.contains(&"bar".to_string()),
+        "bower_components contents must not surface as workspace projects: {names:?}",
+    );
     assert!(names.contains(&"real".to_string()));
 }
🤖 Prompt for 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.

In `@crates/workspace/src/projects/tests.rs` around lines 56 - 77, The test
filters_node_modules should also create a sample project inside
"bower_components" (e.g., call make_project(tmp.path(), "bower_components/bar",
"bar")) and then assert the returned workspace names from
find_workspace_projects (called with FindWorkspaceProjectsOpts { patterns:
Some(vec!["**".to_string()]) }) do NOT contain "bar" (similar to the existing
assertion for "foo"), ensuring bower_components is excluded; add that project
creation and a corresponding assert!(!names.contains(&"bar".to_string())) after
collecting names in the test.
crates/lockfile/src/resolved_dependency/tests.rs (1)

10-10: ⚡ Quick win

Add failure context to non-assert_eq! assertions.

These assert! checks are currently context-free, so failures won’t show the parsed value/state directly. Please add a message (or nearby eprintln!) for quicker diagnosis.

Suggested update
-    assert!(parsed.as_link_target().is_none());
+    assert!(parsed.as_link_target().is_none(), "parsed={parsed:?}");

-    assert!(matches!(parsed, ImporterDepVersion::Regular(_)));
+    assert!(
+        matches!(parsed, ImporterDepVersion::Regular(_)),
+        "parsed={parsed:?}"
+    );

-    assert!(parsed.as_regular().is_none());
+    assert!(parsed.as_regular().is_none(), "parsed={parsed:?}");

-    assert!(spec.version.as_regular().is_some());
+    assert!(
+        spec.version.as_regular().is_some(),
+        "spec.version={:?}",
+        spec.version
+    );

Based on learnings: In Rust test code, add diagnostic logging/context for non-assert_eq! assertions so failures are debuggable without reruns.

Also applies to: 19-19, 28-28, 56-56

🤖 Prompt for 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.

In `@crates/lockfile/src/resolved_dependency/tests.rs` at line 10, The assert!
checks lack failure context — update each to include a diagnostic message
showing the actual value so test failures are debuggable; e.g., replace
assert!(parsed.as_link_target().is_none()) with
assert!(parsed.as_link_target().is_none(), "expected no link target, got: {:?}",
parsed.as_link_target()) or print eprintln!("parsed = {:?}", parsed) immediately
before the assert; apply the same pattern to the other non-assert_eq! assertions
that reference parsed (and its methods like as_link_target()) so failures
surface the parsed state.
🤖 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/package-manager/src/symlink_direct_dependencies/tests.rs`:
- Line 456: The test assumes workspace_root.parent()/node_modules which can
point to a shared system tempdir; instead create and use an explicit
subdirectory inside the tempdir for the workspace root (e.g. let workspace_root
= dir.path().join("workspace"); fs::create_dir_all(&workspace_root)?), then
update assertions that referenced workspace_root.parent().join("node_modules")
to use workspace_root.join("node_modules") (and any other checks in the same
test that use workspace_root.parent()). This ensures the test only inspects
directories created for the test and avoids shared-parent flakes; update code
that sets or asserts on workspace_root accordingly (variable workspace_root in
symlink_direct_dependencies tests).

In `@crates/workspace/src/projects/tests.rs`:
- Line 76: The assertion on the variable `names` (the line with
assert!(names.contains(&"real".to_string()))) lacks diagnostic context; update
this assertion to include a failure message that prints the expected value
"real" and the current contents of `names` (e.g., use an assert! with a
formatted message showing names and the missing value) so test failures show
actual vs expected for easier debugging.

---

Nitpick comments:
In `@crates/lockfile/src/resolved_dependency/tests.rs`:
- Line 10: The assert! checks lack failure context — update each to include a
diagnostic message showing the actual value so test failures are debuggable;
e.g., replace assert!(parsed.as_link_target().is_none()) with
assert!(parsed.as_link_target().is_none(), "expected no link target, got: {:?}",
parsed.as_link_target()) or print eprintln!("parsed = {:?}", parsed) immediately
before the assert; apply the same pattern to the other non-assert_eq! assertions
that reference parsed (and its methods like as_link_target()) so failures
surface the parsed state.

In `@crates/workspace/src/project_manifest/tests.rs`:
- Around line 23-32: Capture the result of try_read_project_manifest(tmp.path())
and safe_read_project_manifest_only(tmp.path()) into a local variable inside the
tests try_read_returns_none_when_missing and
safe_read_returns_none_when_missing, emit diagnostic output (e.g., with
eprintln! or dbg!) showing the evaluated value, then perform the assertion
(prefer assert_eq!(res, None) or assert!(res.is_none())). This ensures you log
the computed value from try_read_project_manifest and
safe_read_project_manifest_only before asserting so failures are diagnosable in
CI.

In `@crates/workspace/src/projects/tests.rs`:
- Around line 56-77: The test filters_node_modules should also create a sample
project inside "bower_components" (e.g., call make_project(tmp.path(),
"bower_components/bar", "bar")) and then assert the returned workspace names
from find_workspace_projects (called with FindWorkspaceProjectsOpts { patterns:
Some(vec!["**".to_string()]) }) do NOT contain "bar" (similar to the existing
assertion for "foo"), ensuring bower_components is excluded; add that project
creation and a corresponding assert!(!names.contains(&"bar".to_string())) after
collecting names in the test.
🪄 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: 4ee1ac94-9a88-4807-88df-f98d4e4c54e5

📥 Commits

Reviewing files that changed from the base of the PR and between eda55b1 and a0d4df8.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (22)
  • Cargo.toml
  • crates/lockfile/src/resolved_dependency.rs
  • crates/lockfile/src/resolved_dependency/tests.rs
  • crates/lockfile/src/save_lockfile/tests.rs
  • crates/package-manager/Cargo.toml
  • crates/package-manager/src/build_modules/tests.rs
  • crates/package-manager/src/build_sequence.rs
  • crates/package-manager/src/build_sequence/tests.rs
  • crates/package-manager/src/install.rs
  • crates/package-manager/src/install_frozen_lockfile.rs
  • crates/package-manager/src/symlink_direct_dependencies.rs
  • crates/package-manager/src/symlink_direct_dependencies/tests.rs
  • crates/workspace/Cargo.toml
  • crates/workspace/src/lib.rs
  • crates/workspace/src/manifest.rs
  • crates/workspace/src/manifest/tests.rs
  • crates/workspace/src/project_manifest.rs
  • crates/workspace/src/project_manifest/tests.rs
  • crates/workspace/src/projects.rs
  • crates/workspace/src/projects/tests.rs
  • crates/workspace/src/root_finder.rs
  • crates/workspace/src/root_finder/tests.rs
✅ Files skipped from review due to trivial changes (4)
  • crates/package-manager/src/build_sequence/tests.rs
  • crates/workspace/src/lib.rs
  • crates/workspace/src/manifest/tests.rs
  • crates/package-manager/src/build_modules/tests.rs
🚧 Files skipped from review as they are similar to previous changes (12)
  • crates/workspace/Cargo.toml
  • crates/package-manager/src/build_sequence.rs
  • crates/lockfile/src/save_lockfile/tests.rs
  • crates/package-manager/src/install_frozen_lockfile.rs
  • crates/workspace/src/root_finder/tests.rs
  • crates/lockfile/src/resolved_dependency.rs
  • crates/workspace/src/root_finder.rs
  • crates/package-manager/src/install.rs
  • crates/workspace/src/projects.rs
  • crates/workspace/src/manifest.rs
  • crates/workspace/src/project_manifest.rs
  • crates/package-manager/src/symlink_direct_dependencies.rs
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (7)
  • GitHub Check: Run benchmark on ubuntu-latest
  • GitHub Check: Agent
  • GitHub Check: Code Coverage
  • GitHub Check: Run benchmark on ubuntu-latest
  • GitHub Check: Lint and Test (windows-latest)
  • GitHub Check: Lint and Test (ubuntu-latest)
  • GitHub Check: Lint and Test (macos-latest)
🧰 Additional context used
📓 Path-based instructions (1)
**/*.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/lockfile/src/resolved_dependency/tests.rs
  • crates/workspace/src/projects/tests.rs
  • crates/workspace/src/project_manifest/tests.rs
  • crates/package-manager/src/symlink_direct_dependencies/tests.rs
🧠 Learnings (2)
📚 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/lockfile/src/resolved_dependency/tests.rs
  • crates/workspace/src/projects/tests.rs
  • crates/workspace/src/project_manifest/tests.rs
  • crates/package-manager/src/symlink_direct_dependencies/tests.rs
📚 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/lockfile/src/resolved_dependency/tests.rs
  • crates/workspace/src/projects/tests.rs
  • crates/workspace/src/project_manifest/tests.rs
  • crates/package-manager/src/symlink_direct_dependencies/tests.rs
🔇 Additional comments (4)
Cargo.toml (1)

34-34: LGTM!

Also applies to: 89-89

crates/package-manager/Cargo.toml (1)

28-28: LGTM!

crates/workspace/src/project_manifest/tests.rs (1)

1-20: LGTM!

Also applies to: 34-67

crates/workspace/src/projects/tests.rs (1)

1-55: LGTM!

Also applies to: 79-117

Comment thread crates/package-manager/src/symlink_direct_dependencies/tests.rs
Comment thread crates/workspace/src/projects/tests.rs Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

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 22 out of 23 changed files in this pull request and generated 2 comments.

Comment thread crates/package-manager/src/install.rs
Comment thread crates/package-manager/src/symlink_direct_dependencies.rs Outdated
zkochan added a commit that referenced this pull request May 13, 2026
Doc job (blocking):
- Replace private intra-doc links to module names in
  `crates/workspace/src/lib.rs` with text + linked public items.
- Drop two `[`pacquet_config::WorkspaceSettings`]` rustdoc links in
  `crates/workspace/src/manifest.rs` — `pacquet-config` is not a
  dependency of `pacquet-workspace`, so the links were broken.

Review feedback:
- `install.rs:111` — replace `to_str().expect(...)` with
  `to_string_lossy().into_owned()` so non-UTF-8 workspace paths do
  not panic the installer.
- `symlink_direct_dependencies.rs` —
  - Validate every importer key (`validate_importer_id`) and reject
    absolute paths, Windows drive prefixes, backslash separators, and
    `..` segments with `SymlinkDirectDependenciesError::UnsafeImporterPath`.
    A malformed lockfile would otherwise let `Path::join` create
    `node_modules` outside the workspace root.
  - Replace `symlink_package(...).expect("symlink pkg")` with a
    fallible `try_for_each` path and a new
    `SymlinkDirectDependenciesError::SymlinkPackage` variant so a per-
    package symlink failure surfaces as a structured error instead of
    panicking the rayon worker.
  - Fix the struct-level doc comment to refer to
    `<workspace_root>/node_modules/.pacquet` (pacquet's actual virtual
    store dir) rather than `.pnpm`.
- `projects.rs:186` — restrict the manifest-read swallow to
  `ENOENT`-equivalent errors (`Io(NotFound)` and
  `NoImporterManifestFound`); parse / permission / other I/O errors
  now propagate via `FindWorkspaceProjectsError::ReadManifest`.
- `root_finder.rs` — treat an empty `NPM_CONFIG_WORKSPACE_DIR` as
  unset so the upward walk takes over, matching upstream's truthy
  `if (workspaceDir)` check.
- `project_manifest.rs` — drop the misleading mention of
  `package.yaml` / `package.json5` from `NoImporterManifestFound`;
  pacquet only probes `package.json` today.
- `manifest.rs` — remove the unreachable
  `InvalidWorkspaceManifestError::PackagesNotArray` variant; serde
  already rejects non-array shapes at parse time.
- `symlink_direct_dependencies/tests.rs` — drop leftover `dbg!`.

New tests:
- `pacquet-package-manager`: `unsafe_importer_keys_error_before_filesystem_writes`
  covers six unsafe importer-key shapes.
- `pacquet-workspace`: `empty_env_var_is_treated_as_unset` covers
  the `NPM_CONFIG_WORKSPACE_DIR=""` fallback.

Verified locally: `just ready` (649 passed), `taplo format --check`,
`just dylint`, and `RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --workspace --document-private-items`
all clean.

---
Written by an agent (Claude Code, claude-opus-4-7).
zkochan added a commit that referenced this pull request May 13, 2026
… keys

Strengthens `unsafe_importer_keys_error_before_filesystem_writes` to
match its name: in addition to the error-type check, it now asserts
that no `node_modules` directory is created under `workspace_root`
*or* its parent for each rejected importer key. The parent-side check
guards against a regression where `Path::join` would let a `..` or
absolute key land outside the workspace root.

Addresses CodeRabbit feedback on PR #443.

---
Written by an agent (Claude Code, claude-opus-4-7).
zkochan added a commit that referenced this pull request May 13, 2026
…ring round-trip

`Install::run` converts the resolved workspace root to a lossy-UTF-8
string for reporter envelopes (`prefix`) and passes that to
`InstallFrozenLockfile::requester`. The previous code then reconstructed
`PathBuf::from(requester)` inside `InstallFrozenLockfile::run` to feed
`SymlinkDirectDependencies` and `BuildModules` — meaning real filesystem
operations were driven by a path that had already passed through a
lossy conversion. On hosts with non-UTF-8 filenames, the on-disk path
could silently diverge from the workspace root pacquet meant to install
under.

Add a `workspace_root: &'a Path` field on `InstallFrozenLockfile`
threaded straight from `Install::run` (which still holds the original
`PathBuf` returned by `find_workspace_dir`). The lossy `requester`
string remains for reporter envelopes only; filesystem-effecting code
paths (`SymlinkDirectDependencies::workspace_root`, `BuildModules`'s
`lockfile_dir`) now read the real path directly.

Caught by Copilot in PR #443 review of crates/package-manager/src/install_frozen_lockfile.rs:158.

---
Written by an agent (Claude Code, claude-opus-4-7).
zkochan added a commit that referenced this pull request May 13, 2026
Two items from the 2026-05-13 Copilot review:

1. `validate_importer_id` previously accepted an empty importer key as
   equivalent to `"."`. Pnpm only ever writes `.` for the root importer;
   an empty-string key is non-standard and could mask a malformed
   lockfile. Reject it with `UnsafeImporterPath` instead. The
   `importer_root_dir` lookup is correspondingly tightened to the
   single canonical root key; the empty-key fallback is unreachable
   after validation. New test case (empty string) added to
   `unsafe_importer_keys_error_before_filesystem_writes`.

2. `empty_env_var_is_treated_as_unset` was calling `unsafe { env::set_var }`,
   which is documented UB when other threads access the process
   environment concurrently — and Rust's default test runner is
   multi-threaded. Refactor `find_workspace_dir_from_env` to accept an
   injected env accessor (`find_workspace_dir_from_env_with`) and have
   the production path call it with `std::env::var_os`. Tests now
   exercise the fall-through, truthy-value, and lowercase-fallback
   branches via pure in-memory closures with no `set_var` calls.

---
Written by an agent (Claude Code, claude-opus-4-7).
zkochan added 13 commits May 13, 2026 15:39
Stage 1 of pnpm/pacquet#299: enumerate `Lockfile.importers` and
materialise per-project `node_modules/` against the shared virtual
store. Ports four upstream packages into a new `pacquet-workspace`
crate (`workspace/root-finder`, `workspace/workspace-manifest-reader`,
`workspace/projects-reader`, `workspace/project-manifest-reader` —
pinned to pnpm/pnpm@94240bc046) and refactors the installer so the
"missing root importer" hard-failure becomes per-importer iteration.

New crate: `pacquet-workspace`
- `find_workspace_dir`: walk up to `pnpm-workspace.yaml`, honour
  `NPM_CONFIG_WORKSPACE_DIR`, reject misnamed variants
  (`pnpm-workspace.yml`, `.pnpm-workspace.yaml`, …) with the same
  `BAD_WORKSPACE_MANIFEST_NAME` upstream raises.
- `read_workspace_manifest`: parse `packages:` (and tolerate
  settings-only manifests).
- `find_workspace_projects` / `find_workspace_projects_no_check`:
  glob-expand `packages:` via `wax`, always include the workspace
  root, filter `**/node_modules/**` and `**/bower_components/**`,
  sort lex by `rootDir`.
- `read_project_manifest_only` / `try_read_project_manifest` /
  `read_exact_project_manifest`: thin wrappers over pacquet's
  existing `PackageManifest::from_path`.

Installer changes:
- `SymlinkDirectDependencies` now takes `workspace_root` and
  iterates every importer (sorted for determinism). Per-importer
  `node_modules/` and `pnpm:root added` `prefix = rootDir` match
  upstream's `linkDirectDeps`. The install-wide `prefix` (pnpm:stage,
  pnpm:summary) stays at `lockfileDir` as upstream does — partially
  closes #357 by resolving that path through `find_workspace_dir`.
- `Lockfile.importers[*].dependencies` now decodes `link:<path>`
  values into a new `ImporterDepVersion::Link` variant. The symlink
  stage resolves the path against the importer's `rootDir` and
  points `node_modules/<name>` at the sibling project rather than
  the virtual store. `link:` deps are also skipped when building
  the snapshot-key roots for `BuildModules`.
- `MissingRootImporter` is gone — an empty importers map is a
  silent no-op now, mirroring pnpm's per-importer treatment.

Adds `wax = 0.7.0` to `[workspace.dependencies]` for project
globbing.

Tests added:
- `pacquet-workspace`: 21 unit tests covering env-var override,
  misnamed-variant rejection, settings-only manifests, glob expansion
  with always-include-root, `node_modules` exclusion, dedup across
  overlapping patterns, default `['.', '**']` patterns.
- `pacquet-lockfile`: workspace lockfile round-trip
  (two importers, one `workspace:*` → `link:../shared`).
- `pacquet-package-manager`: per-importer `pnpm:root` prefix,
  empty-importers no-op, cross-importer `link:` symlinks to sibling
  `rootDir`.

Out of scope (Stage 2 of #299): `--filter`, projects-graph,
`engines`/`os`/`cpu` installability filter at project enumeration,
the `resolutions` non-root warning, real-path resolution for
case-insensitive filesystems.

---
Written by an agent (Claude Code, claude-opus-4-7).
Doc job (blocking):
- Replace private intra-doc links to module names in
  `crates/workspace/src/lib.rs` with text + linked public items.
- Drop two `[`pacquet_config::WorkspaceSettings`]` rustdoc links in
  `crates/workspace/src/manifest.rs` — `pacquet-config` is not a
  dependency of `pacquet-workspace`, so the links were broken.

Review feedback:
- `install.rs:111` — replace `to_str().expect(...)` with
  `to_string_lossy().into_owned()` so non-UTF-8 workspace paths do
  not panic the installer.
- `symlink_direct_dependencies.rs` —
  - Validate every importer key (`validate_importer_id`) and reject
    absolute paths, Windows drive prefixes, backslash separators, and
    `..` segments with `SymlinkDirectDependenciesError::UnsafeImporterPath`.
    A malformed lockfile would otherwise let `Path::join` create
    `node_modules` outside the workspace root.
  - Replace `symlink_package(...).expect("symlink pkg")` with a
    fallible `try_for_each` path and a new
    `SymlinkDirectDependenciesError::SymlinkPackage` variant so a per-
    package symlink failure surfaces as a structured error instead of
    panicking the rayon worker.
  - Fix the struct-level doc comment to refer to
    `<workspace_root>/node_modules/.pacquet` (pacquet's actual virtual
    store dir) rather than `.pnpm`.
- `projects.rs:186` — restrict the manifest-read swallow to
  `ENOENT`-equivalent errors (`Io(NotFound)` and
  `NoImporterManifestFound`); parse / permission / other I/O errors
  now propagate via `FindWorkspaceProjectsError::ReadManifest`.
- `root_finder.rs` — treat an empty `NPM_CONFIG_WORKSPACE_DIR` as
  unset so the upward walk takes over, matching upstream's truthy
  `if (workspaceDir)` check.
- `project_manifest.rs` — drop the misleading mention of
  `package.yaml` / `package.json5` from `NoImporterManifestFound`;
  pacquet only probes `package.json` today.
- `manifest.rs` — remove the unreachable
  `InvalidWorkspaceManifestError::PackagesNotArray` variant; serde
  already rejects non-array shapes at parse time.
- `symlink_direct_dependencies/tests.rs` — drop leftover `dbg!`.

New tests:
- `pacquet-package-manager`: `unsafe_importer_keys_error_before_filesystem_writes`
  covers six unsafe importer-key shapes.
- `pacquet-workspace`: `empty_env_var_is_treated_as_unset` covers
  the `NPM_CONFIG_WORKSPACE_DIR=""` fallback.

Verified locally: `just ready` (649 passed), `taplo format --check`,
`just dylint`, and `RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --workspace --document-private-items`
all clean.

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

Strengthens `unsafe_importer_keys_error_before_filesystem_writes` to
match its name: in addition to the error-type check, it now asserts
that no `node_modules` directory is created under `workspace_root`
*or* its parent for each rejected importer key. The parent-side check
guards against a regression where `Path::join` would let a `..` or
absolute key land outside the workspace root.

Addresses CodeRabbit feedback on PR #443.

---
Written by an agent (Claude Code, claude-opus-4-7).
…ring round-trip

`Install::run` converts the resolved workspace root to a lossy-UTF-8
string for reporter envelopes (`prefix`) and passes that to
`InstallFrozenLockfile::requester`. The previous code then reconstructed
`PathBuf::from(requester)` inside `InstallFrozenLockfile::run` to feed
`SymlinkDirectDependencies` and `BuildModules` — meaning real filesystem
operations were driven by a path that had already passed through a
lossy conversion. On hosts with non-UTF-8 filenames, the on-disk path
could silently diverge from the workspace root pacquet meant to install
under.

Add a `workspace_root: &'a Path` field on `InstallFrozenLockfile`
threaded straight from `Install::run` (which still holds the original
`PathBuf` returned by `find_workspace_dir`). The lossy `requester`
string remains for reporter envelopes only; filesystem-effecting code
paths (`SymlinkDirectDependencies::workspace_root`, `BuildModules`'s
`lockfile_dir`) now read the real path directly.

Caught by Copilot in PR #443 review of crates/package-manager/src/install_frozen_lockfile.rs:158.

---
Written by an agent (Claude Code, claude-opus-4-7).
Two items from the 2026-05-13 Copilot review:

1. `validate_importer_id` previously accepted an empty importer key as
   equivalent to `"."`. Pnpm only ever writes `.` for the root importer;
   an empty-string key is non-standard and could mask a malformed
   lockfile. Reject it with `UnsafeImporterPath` instead. The
   `importer_root_dir` lookup is correspondingly tightened to the
   single canonical root key; the empty-key fallback is unreachable
   after validation. New test case (empty string) added to
   `unsafe_importer_keys_error_before_filesystem_writes`.

2. `empty_env_var_is_treated_as_unset` was calling `unsafe { env::set_var }`,
   which is documented UB when other threads access the process
   environment concurrently — and Rust's default test runner is
   multi-threaded. Refactor `find_workspace_dir_from_env` to accept an
   injected env accessor (`find_workspace_dir_from_env_with`) and have
   the production path call it with `std::env::var_os`. Tests now
   exercise the fall-through, truthy-value, and lowercase-fallback
   branches via pure in-memory closures with no `set_var` calls.

---
Written by an agent (Claude Code, claude-opus-4-7).
Two cleanups while rebasing on top of #442 (partial frozen install):

- `install.rs`: the comment at the `<virtual_store_dir>/lock.yaml`
  write site referenced #431 as future work. With workspace install
  now landing in this PR, refresh the wording to call out that we
  *do* ship every importer's section unchanged, and the remaining
  narrowing (selected importers × engine filter) is gated on
  `--filter` (Stage 2 of #299).
- `root_finder/tests.rs`: rustfmt collapses the if/else closures
  introduced in a0d4df8 to single-line `if … else …` form. Apply
  it once here so the diff doesn't keep returning across runs.

---
Written by an agent (Claude Code, claude-opus-4-7).
…n review nits

Four follow-ups from the post-rebase review:

- `pacquet-config` (Copilot, real bug): when `pacquet install` runs from
  a workspace subdirectory, `Config::current` re-anchored
  `modules_dir` and `virtual_store_dir` to the CLI `--dir` (the
  subdir) while [`pacquet_workspace::find_workspace_dir`] still
  routed the per-importer `SymlinkDirectDependencies` writes under
  the workspace root. The two layers disagreed about where the
  install actually lived. Mirror pnpm v11's `pnpmConfig.dir = lockfileDir`
  rule: when `WorkspaceSettings::find_and_load` finds a
  `pnpm-workspace.yaml` ancestor, re-anchor `modules_dir` and
  `virtual_store_dir` to that workspace dir *before* applying
  settings, so an explicit `modulesDir` / `virtualStoreDir` in the
  yaml still wins. Two new tests pin the workspace-subdir and
  single-project cases.

- `symlink_direct_dependencies.rs` doc comment (Copilot): hard-coded
  `.pacquet` and claimed it was fixed by `default_virtual_store_dir`,
  but pacquet's actual default is `node_modules/.pnpm`. Reword to
  point readers at `config.virtual_store_dir` (the source of truth)
  while noting the default.

- `unsafe_importer_keys_error_before_filesystem_writes`
  (CodeRabbit): the assertion that the workspace_root's *parent*
  has no `node_modules` could spuriously fail when the tempdir's
  parent is a shared system temp directory populated by an
  unrelated process. Drop it; keep the workspace_root-side check.

- `projects::tests::filters_node_modules` (CodeRabbit): add a
  diagnostic payload to the bare `assert!` so a failure tells you
  which projects the enumeration actually surfaced.

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

`Install::run` resolves the workspace root through
[`pacquet_workspace::find_workspace_dir`], which honors
`NPM_CONFIG_WORKSPACE_DIR` (and its lowercase spelling) as an explicit
override. Until now, `Config::current` only walked up for
`pnpm-workspace.yaml`, so a user setting the env var would get:

  - per-importer `SymlinkDirectDependencies` writes under the env-var dir,
  - virtual store under the CLI `--dir`'s `node_modules/.pnpm`,

i.e. two `node_modules` layouts for the same install — the same
inconsistency the workspace-subdir bug had before commit 5f5803b, just
through a different discovery path.

Mirror the env-var override in `Config::current`. When the env var is
set (non-empty), use it as the workspace dir directly: load
`pnpm-workspace.yaml` from there if present (silent on ENOENT,
matching upstream's truthy `if (workspaceDir)` check), and re-anchor
`modules_dir` / `virtual_store_dir` to that path before applying the
yaml settings. The cwd-walk path stays as a fallback when the env var
is unset.

The env-var lookup is inlined here rather than via
`pacquet_workspace::find_workspace_dir_from_env` to avoid adding a
cross-crate dependency just for the four-line read — the contract is
fixed by pnpm upstream, so the duplication is low-risk.

Three new tests:

- `npm_config_workspace_dir_re_anchors_modules` covers the override case.
- `empty_npm_config_workspace_dir_falls_through` pairs with
  `pacquet_workspace`'s `empty_env_var_is_treated_as_unset`.
- `single_project_anchors_modules_at_cwd` now snapshots the env vars
  via `EnvGuard` so a concurrent env-var test cannot make it observe
  the override path.

Caught by Copilot in PR #443 review of crates/config/src/lib.rs:618.

---
Written by an agent (Claude Code, claude-opus-4-7).
After rebasing on top of #448 (new `pacquet-real-hoist` crate), the
lockfile-to-HoisterTree wrapper consumed `spec.version` expecting a
`PkgVerPeer` — but this PR widens `ResolvedDependencySpec.version` to
`ImporterDepVersion` so `link:` (cross-importer `workspace:*`) deps
can be represented. `link:` deps don't live in the virtual store, so
they have no snapshot to hoist; mirror the same skip that
`pacquet-package-manager`'s `build_sequence::collect_root_dep_paths`
already does and `continue` past `ImporterDepVersion::Link` values.

The matching `resolved_dep` test helper takes a `&str` literal and is
unit-test infrastructure, so a `.into()` from `PkgVerPeer` to
`ImporterDepVersion::Regular` is enough — no functional change.

---
Written by an agent (Claude Code, claude-opus-4-7).
Three follow-ups from CI and PR review:

- Windows test failure
  (`npm_config_workspace_dir_re_anchors_modules`): Windows env vars
  are case-insensitive, so
  `set_var("NPM_CONFIG_WORKSPACE_DIR", x)` followed by
  `remove_var("npm_config_workspace_dir")` was clearing the value
  the test had just set. The runner observed "no env override" and
  the assertion fired. Fix: in the re-anchor test set only the
  uppercase spelling (the production lookup checks it first, so an
  externally-set lowercase is unobservable here); in the
  empty-falls-through test set both spellings to "" so the truthy
  filter rejects them on both platforms.

- Copilot review (symlink_direct_dependencies.rs:133): the
  per-importer modules dir was hard-coded to `node_modules`,
  ignoring `Config.modules_dir` (and any `modulesDir` override in
  `pnpm-workspace.yaml`). Derive the suffix from
  `config.modules_dir.file_name()` and apply it under each importer
  so all stages (symlink, `.modules.yaml` write, bin link) agree.
  New test `custom_modules_dir_propagates_to_each_importer` pins
  the contract by configuring `config.modules_dir = .../custom_modules`
  and asserting the symlink lands under `<importer>/custom_modules/`,
  with no `<importer>/node_modules/` materialised.

- Copilot review (symlink_direct_dependencies/tests.rs:99):
  removed two leftover `eprintln!` debug calls and folded the link
  paths into the `assert!` messages so failures still carry the
  same context without the unconditional stderr noise.

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

Three follow-ups from Copilot's latest review:

- `WorkspaceManifest.packages` was `Vec<String>` with
  `#[serde(default)]`, collapsing the omitted and explicit-empty
  cases into the same `Vec::new()`. Upstream's
  `opts.patterns ?? defaults` only falls back when the key is
  absent, so an explicit `packages: []` should enumerate only the
  workspace root, not recurse via the `['.', '**']` defaults.
  Switch to `Option<Vec<String>>` so `None` (omitted) maps to the
  defaults and `Some(vec![])` is preserved.

- `find_workspace_projects_no_check` consequently changes from
  `.filter(|p| !p.is_empty()).unwrap_or(&default_patterns)` to a
  `match` on the `Option`, defaulting only on `None`. Two new
  tests pin the contract: `empty_packages_array_preserved_as_some_empty`
  at the manifest layer, and `empty_patterns_array_enumerates_root_only`
  at the projects-reader layer.

- The `wax::any(IGNORE_PATTERNS...)` matcher was being rebuilt for
  every user-supplied `packages:` pattern. `wax::Glob` and `wax::Any`
  both derive `Clone`, so hoist the matcher above the loop and
  `.clone()` it into each `Walk::not` call instead of reparsing the
  constant ignore list each iteration.

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

After rebasing on top of #439 (`pacquet-package-is-installable` + the
`SkippedSnapshots` set), the new test sites this PR added —
`per_importer_prefix_in_pnpm_root_events`,
`unsafe_importer_keys_error_before_filesystem_writes`,
`cross_importer_link_dep_symlinks_to_sibling_rootdir`, and
`custom_modules_dir_propagates_to_each_importer` — needed the new
`skipped` field on their `SymlinkDirectDependencies` builders. Pass
`&SkippedSnapshots::default()` everywhere; the per-importer install
behaviour these tests cover is orthogonal to the installability
filter, so an empty skipped set keeps the assertion subject the
same.

---
Written by an agent (Claude Code, claude-opus-4-7).
Origin/main picked up #451 (`feat(git-fetcher): install git-hosted
tarballs via preparePackage + packlist`) which adds a required `path:
Option<String>` field to `TarballResolution`. The
`synthetic_metadata` helper in `installability/tests.rs` builds the
struct literally and so needs the new field. Set it to `None` — the
installability check doesn't look at `path`, so an unset value
preserves the test's existing assertion subject (engines / cpu / os /
libc filtering).

---
Written by an agent (Claude Code, claude-opus-4-7).
Copilot AI review requested due to automatic review settings May 13, 2026 13:42

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

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 zkochan merged commit 8b35794 into main May 13, 2026
18 of 20 checks passed
@zkochan zkochan deleted the feat/431 branch May 13, 2026 13:52
zkochan added a commit that referenced this pull request May 13, 2026
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.
zkochan added a commit that referenced this pull request May 13, 2026
…ort)

#443 (workspace support for `--frozen-lockfile`, the #431 issue
pacquet/pacquet#432 explicitly flagged as a follow-up) just landed.
With it, the lockfile's `importers` map can carry multiple entries,
and the install pipeline materialises a `node_modules/` for each
sibling project. The project-registry write needs to follow suit:
upstream pnpm registers every importer separately under
`<store_dir>/projects/<short-hash>` so the prune sweep can keep
every reachable consumer of `<store_dir>/links/...` alive.

Changes:

- `Install::run` now iterates `lockfile.importers.keys()` and
  registers each importer's project directory (computed via the
  newly-exposed `pub(crate) importer_root_dir(...)` from
  `symlink_direct_dependencies`). Replaces the previous
  one-shot registration of the workspace root, which would have
  left sub-importer entries missing from the registry.
- The registry write moves to *after* `InstallFrozenLockfile::run`
  succeeds so the importer keys have already been validated by
  `SymlinkDirectDependencies::run`'s `validate_importer_id` —
  no more pre-validation entries for malformed lockfiles.
- Gating unchanged: still only fires under
  `frozen_lockfile && config.enable_global_virtual_store`.
- New test `frozen_lockfile_under_gvs_registers_each_workspace_importer`
  walks the multi-importer case: workspace root + `packages/web`
  each end up with their own symlink in `<store_dir>/projects/`,
  resolving back to the right directory.

All 829 workspace tests pass; taplo / dylint / cargo doc clean.
zkochan added a commit that referenced this pull request May 13, 2026
## Summary

Replaces the stub `nm_hoist` (landed in #448) with a working hoister and the surrounding guardrails: BFS over the result graph pulling every eligible descendant up to the root, plus upfront refusal of inputs the algorithm doesn't yet model.

### What the algorithm models

- **Free hoist.** A transitive dep with no name collision at the root surfaces at the root.
- **Identity dedup.** A dep reachable through multiple parents shares one `Rc` thanks to the wrapper's cache; the hoist preserves that identity and strips the duplicate reference at the other parent path.
- **Parent-wins on version conflict.** When two distinct deps share an alias but resolve to different snapshot keys, the first BFS visitor takes the root slot and the other stays under its declaring parent. Visit order matches the wrapper's alias-sorted insertion order, so the outcome is deterministic.
- **Deep chains flatten in one pass.** `root → a → b → c → d` becomes `root → {a, b, c, d}` — each node, once hoisted, is queued for further descent; its own children evaluate against root's slots rather than against the (now-empty) intermediate parent.
- **O(1) root-slot lookup.** A side `HashMap<String, RcByPtr<HoisterResult>>` mirrors root's direct deps, so the per-edge "is this name taken?" check doesn't degrade to O(N²) `IndexSet` scans on flat graphs.

### Fail-fast guards

The algorithm doesn't yet model peers, hoisting limits, or multi-importer (workspace) hoist trees. Rather than emit a layout pnpm would reject, the wrapper refuses these inputs upfront with three new `HoistError` variants:

- `UnsupportedPeerDependency { ident, peers }` — fires when scanning the constructed `HoisterTree` finds any node with non-empty `peer_names` (either `peerDependencies` from the `packages:` map or `transitive_peer_dependencies` on a `snapshots:` entry).
- `UnsupportedHoistingLimits { len }` — non-empty `opts.hoisting_limits`.
- `UnsupportedWorkspace { extra_importers }` — any importer beyond the root `.`.

Each carries enough context for an operator to identify what triggered it. The `UnsupportedWorkspace` help points at `SymlinkDirectDependencies` — workspaces *do* work in pacquet's wider install path (workspace support landed in #443 for the isolated linker); only the hoister is restricted.

### Rebase pickup

`collect_importer_deps` carries the `Link`-variant skip introduced by #443 (workspace support) — `spec.version.as_regular()` extracts the snapshot key for `Regular` deps and `continue`s past `Link` deps, since workspace siblings are direct symlinks materialised by `SymlinkDirectDependencies` and have no snapshot to hoist.

### Performance

Nothing in this PR is reachable from `pacquet install` today (`crates/package-manager/src/install*.rs` doesn't import `pacquet-real-hoist` — the crate is dead code from the install pipeline's perspective until the hoisted-linker wiring lands in later slices). The benchmark results bear that out: cold Frozen Lockfile is within CI variance (+3.2% mean, +3.5% median, driven by one outlier), Hot Cache is 6% *faster* (same `stat → lstat` improvement that shipped in slice 1's `import_indexed_dir`), micro is identical. No code path explains a real regression.

## What's deferred

- Peer-dependency-aware hoisting (`peer_names` constraints, peer-promise satisfaction).
- Multi-round convergence — the BFS handles deep chains in one pass, so the cases requiring true multi-round are limited to peer interactions.
- `hoistingLimits` runtime enforcement.
- `dependencyKind` distinctions for workspaces and external soft links (today only `ExternalSoftLink` placeholders are added by the wrapper and stripped post-hoist; `Workspace`-kind nodes are blocked by the `UnsupportedWorkspace` guard upfront).

## Upstream reference

- [`hoist.ts` algorithm overview](https://github.com/yarnpkg/berry/blob/4287909fa6a0a1ec976a55776bff606864b31990/packages/yarnpkg-nm/sources/hoist.ts#L1-L51) — the recipe pacquet's single-pass BFS approximates.
- [`hoistTo` main loop](https://github.com/yarnpkg/berry/blob/4287909fa6a0a1ec976a55776bff606864b31990/packages/yarnpkg-nm/sources/hoist.ts#L329) — the structural intent the port mirrors for the subset above.
zkochan added a commit that referenced this pull request May 13, 2026
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.
zkochan added a commit that referenced this pull request May 13, 2026
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.
zkochan added a commit that referenced this pull request May 13, 2026
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.
zkochan added a commit that referenced this pull request May 13, 2026
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.
zkochan added a commit that referenced this pull request May 13, 2026
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.
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 workspace support to pacquet install --frozen-lockfile Resolve workspace dir for install prefix

2 participants