Skip to content

feat(cli): add pacquet why command#12497

Merged
zkochan merged 10 commits into
pnpm:mainfrom
kairosci:feat/pacquet-why
Jun 19, 2026
Merged

feat(cli): add pacquet why command#12497
zkochan merged 10 commits into
pnpm:mainfrom
kairosci:feat/pacquet-why

Conversation

@kairosci

@kairosci kairosci commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds pacquet why command that shows which packages depend on a given package, porting pnpm's why command to the Rust pacquet/ stack.

The command reads the lockfile, builds a forward dependency graph, inverts it, and walks the reverse edges to display a tree of dependents up to the root project. Supports glob patterns (@scope/*, !exclude) and --depth to limit display depth.

Squash Commit Body

Add `pacquet why <pkg>` command that shows the reverse dependency tree
for a given package. Reads the lockfile, inverts the dependency graph,
and renders a tree with cycle detection and deduplication.

Flags: --depth <n>, --exclude-peers (reserved for parity).

Checklist

  • The change is implemented in both the TypeScript CLI and the Rust pacquet/ port, or the description notes what still needs porting.
    • pnpm already has why. This PR ports it to pacquet. No TypeScript changes needed.
  • Added a changeset (pnpm changeset) if this PR changes any published package.
    • No published package changed (pacquet is not published to npm).
  • Added or updated tests.
    • 5 unit tests + 5 integration tests.
  • Updated the documentation if needed.

Summary by CodeRabbit

New Features

  • Introduced a new pacquet why subcommand that displays reverse dependency trees for specified packages, including support for alias/name matching.
  • Added options to limit how deep the dependency tree is traversed and to render a readable Unicode tree with package version details.
  • Added CLI validation to reject empty package queries and to show clear errors when a lockfile is missing.

Bug Fixes

Copilot AI review requested due to automatic review settings June 18, 2026 12:27
@kairosci kairosci requested a review from zkochan as a code owner June 18, 2026 12:27
@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jun 18, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (17) 📘 Rule violations (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Memoization breaks traversal 🐞 Bug ≡ Correctness
Description
walk_reverse() memoizes results only by (node_key, depth), but the returned subtree depends on
the caller-provided visited/expanded sets used for cycle detection and deduplication, so cached
subtrees can be reused in a different context and silently truncate or distort the output. This can
yield incorrect reverse dependency trees on graphs with cycles and/or shared ancestors.
Code

pacquet/crates/cli/src/cli_args/why.rs[R337-374]

+    let memo_key = (node_key.clone(), depth);
+    if let Some(cached) = memo.get(&memo_key) {
+        return cached.clone();
+    }
+
+    let mut dependents = Vec::new();
+
+    for (parent_node, _alias) in edges {
+        match parent_node {
+            ParentNode::Importer(importer_id) => {
+                let info = ctx.importer_info.get(importer_id.as_str());
+                dependents.push(DependentNode {
+                    name: info.map_or_else(|| importer_id.clone(), |i| i.name.clone()),
+                    version: info.map_or_else(String::new, |i| i.version.clone()),
+                    dep_field: None,
+                    dependents: vec![],
+                });
+            }
+            ParentNode::Package(parent_key) => {
+                if visited.contains(parent_node) {
+                    dependents.push(DependentNode {
+                        name: parent_key.name.to_string(),
+                        version: display_version(parent_key, ctx.packages),
+                        dep_field: None,
+                        dependents: vec![],
+                    });
+                    continue;
+                }
+
+                if expanded.contains(parent_node) {
+                    dependents.push(DependentNode {
+                        name: parent_key.name.to_string(),
+                        version: display_version(parent_key, ctx.packages),
+                        dep_field: None,
+                        dependents: vec![],
+                    });
+                    continue;
+                }
Evidence
The function returns early from memo before doing any work, but later in the same function the
recursion/expansion decisions explicitly depend on visited and expanded, meaning the computed
Vec is context-dependent and unsafe to cache solely by (node_key, depth).

pacquet/crates/cli/src/cli_args/why.rs[320-409]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`walk_reverse()` caches computed dependents using a key of `(ReverseKey, depth)`, but the computed vector depends on `visited` and `expanded` (cycle detection + dedupe state) which are not part of the cache key. This makes the cache unsafe: if a node is first computed in a context where some parents are considered already `visited`/`expanded`, later callers can incorrectly reuse that truncated subtree.
## Issue Context
The traversal intentionally branches on `visited`/`expanded` to decide whether to recurse or emit a leaf node; therefore the function result is not purely a function of `(node_key, depth)`.
## Fix Focus Areas
- pacquet/crates/cli/src/cli_args/why.rs[337-409]
## Suggested fix direction
- Either remove memoization entirely (simplest correctness fix), or
- Make memoization context-independent by redesigning the traversal so the cached value cannot depend on `visited`/`expanded` (e.g., cache only a canonical full expansion and apply cycle/dedupe handling at render time), or
- If you must keep traversal-time caching, include sufficient context in the cache key (note: including full `visited` sets will likely explode state size).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Link dependents omitted 🐞 Bug ≡ Correctness
Description
pacquet why silently drops link: dependencies when building the reverse-dependency graph, so
workspace-sibling dependents recorded as link: never show up in the output. This makes the command
return incomplete/empty trees in common workspace/monorepo setups.
Code

pacquet/crates/cli/src/cli_args/why.rs[R153-183]

+        if let Some(deps) = &snapshot.dependencies {
+            for (alias, dep_ref) in deps {
+                edges.push((alias.to_string(), dep_ref.resolve(alias)));
+            }
+        }
+
+        if let Some(optional_deps) = &snapshot.optional_dependencies {
+            for (alias, dep_ref) in optional_deps {
+                edges.push((alias.to_string(), dep_ref.resolve(alias)));
+            }
+        }
+
+        forward_edges.insert(key.clone(), edges);
+    }
+
+    let mut reverse_map: HashMap<PkgNameVerPeer, Vec<(ParentNode, String)>> = HashMap::new();
+
+    let groups = [DependencyGroup::Prod, DependencyGroup::Dev, DependencyGroup::Optional];
+    for importer_id in importer_info.keys() {
+        let Some(importer) = lockfile.importers.get(importer_id.as_str()) else {
+            continue;
+        };
+        for group in groups {
+            if let Some(deps) = importer.get_map_by_group(group) {
+                for (alias, spec) in deps {
+                    if let Some(target_key) = spec.version.resolved_key(alias) {
+                        reverse_map
+                            .entry(target_key)
+                            .or_default()
+                            .push((ParentNode::Importer(importer_id.clone()), alias.to_string()));
+                    }
Evidence
The reverse graph only records edges when dep_ref.resolve(...) / spec.version.resolved_key(...)
returns Some(key), but both APIs explicitly return None for link: dependencies, and the
lockfile format includes such link: entries.

pacquet/crates/cli/src/cli_args/why.rs[153-183]
pacquet/crates/lockfile/src/snapshot_dep_ref.rs[58-70]
pacquet/crates/lockfile/src/resolved_dependency.rs[148-169]
pacquet/crates/lockfile/src/load_lockfile/tests.rs[206-269]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`pacquet why` builds its reverse dependency graph using helper APIs that intentionally return `None` for `link:` references. Because the current code only adds edges when those helpers return `Some(key)`, all `link:` edges are omitted, so workspace-sibling dependents are never reported.
## Issue Context
- Snapshot-level deps call `SnapshotDepRef::resolve`, which returns `None` for `SnapshotDepRef::Link`.
- Importer-level deps call `ImporterDepVersion::resolved_key`, which returns `None` for `ImporterDepVersion::Link`.
- The lockfile parser explicitly supports `link:` entries, and tests assert that `resolve()` for `link:` returns `None`.
## Fix Focus Areas
- pacquet/crates/cli/src/cli_args/why.rs[153-183]
### Suggested fix approach
1. Represent `link:` dependencies as nodes/keys in the reverse graph instead of dropping them. Options:
- Introduce a separate node-key type for link targets (e.g., `ReverseKey::Link { name, target }`), or
- Encode a pseudo `PkgNameVerPeer` key for `link:` using the alias name and a parsed `PkgVerPeer` of `link:{target}` (since parsing supports the `link:` shape).
2. Update graph construction to add reverse edges for `link:` deps from both importer deps and snapshot deps.
3. Update result enumeration to include these link-nodes as possible roots when matching (today it only iterates `lockfile.packages.keys()`, which excludes `link:` nodes).
4. Add integration/unit tests that cover `pacquet why <workspace-pkg>` where the dependency is recorded as `link:` in importers/snapshots, asserting the dependent path is shown.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Peer variants skipped 🐞 Bug ≡ Correctness
Description
build_dependents_tree iterates lockfile.packages keys and then looks up lockfile.snapshots
using the same key, which can skip peer-suffixed snapshot entries entirely. Skipped snapshots never
contribute outgoing edges, so pacquet why can miss dependents for packages that are only present
as peer-variant snapshots.
Code

pacquet/crates/cli/src/cli_args/why.rs[R83-96]

+fn build_dependents_tree(lockfile: &Lockfile, matcher: &Matcher) -> Vec<WhyResult> {
+    let Some(packages) = lockfile.packages.as_ref() else {
+        return vec![];
+    };
+
+    let snapshots = lockfile.snapshots.as_ref();
+
+    let mut forward_edges: HashMap<PkgNameVerPeer, Vec<(String, Option<PkgNameVerPeer>)>> =
+        HashMap::new();
+
+    for key in packages.keys() {
+        let Some(snapshot) = snapshots.and_then(|s| s.get(key)) else {
+            continue;
+        };
Evidence
The why command’s graph construction assumes packages: and snapshots: share identical keys,
but the lockfile model explicitly distinguishes snapshot keys (peer-context) from package metadata
keys (peer-independent). As a result, snapshot entries that don’t exist under the packages: key
are skipped, so their edges are never inverted into the reverse map and their dependents can’t
appear in the output.

pacquet/crates/cli/src/cli_args/why.rs[83-96]
pacquet/crates/lockfile/src/pkg_name_ver_peer.rs[39-46]
pacquet/crates/package-manager/src/create_virtual_store.rs[405-430]
pacquet/crates/lockfile/src/package_metadata.rs[5-9]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`pacquet why` currently builds its forward dependency edges by iterating `lockfile.packages.keys()` and then querying `lockfile.snapshots.get(key)`. In v9 lockfiles, `packages:` is keyed by the peer-stripped key, while `snapshots:` is keyed by the full peer-context key; this can cause snapshot entries (notably peer variants) to be skipped, leading to missing dependents.
### Issue Context
Elsewhere in the codebase, snapshot keys are explicitly mapped to `packages:` keys via `PkgNameVerPeer::without_peer()`, indicating these maps are not interchangeable.
### Fix Focus Areas
- pacquet/crates/cli/src/cli_args/why.rs[83-113]
- pacquet/crates/cli/src/cli_args/why.rs[151-170]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (2)
4. Workspace importers not included 🐞 Bug ≡ Correctness
Description
build_dependents_tree only seeds reverse edges from lockfile.root_project() (importer id "."),
so dependencies that are only referenced by other workspace importers never get a path to a root and
the rendered why tree can be incomplete in monorepos. This can cause pacquet why  to stop at a
workspace package rather than showing which workspace project(s) ultimately depend on it.
Code

pacquet/crates/cli/src/cli_args/why.rs[R114-131]

+    if let Some(importer) = lockfile.root_project() {
+        let groups = [DependencyGroup::Prod, DependencyGroup::Dev, DependencyGroup::Optional];
+        for group in groups {
+            if let Some(deps) = importer.get_map_by_group(group) {
+                for (alias, spec) in deps {
+                    if let Some(target_key) = spec.version.resolved_key(alias) {
+                        reverse_map.entry(target_key).or_default().push((
+                            PkgNameVerPeer::new(
+                                PkgName::parse(".").unwrap(),
+                                "0.0.0".parse::<PkgVerPeer>().unwrap(),
+                            ),
+                            alias.to_string(),
+                        ));
+                    }
+                }
+            }
+        }
+    }
Evidence
The implementation only consults lockfile.root_project() (importer key ".") when adding
importer-to-dependency edges, even though the lockfile format supports multiple importer ids via
Lockfile.importers. The repo’s workspace tests show lockfiles containing non-root importer ids
(e.g. project-2), which pacquet why currently ignores, leading to missing roots in the reverse
tree for workspace projects.

pacquet/crates/cli/src/cli_args/why.rs[114-131]
pacquet/crates/lockfile/src/lib.rs[188-228]
pacquet/crates/cli/tests/inject_workspace_packages.rs[117-166]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`pacquet why` builds a reverse dependency graph but only adds importer-origin edges for `lockfile.root_project()` (the `importers["."]` entry). In workspace lockfiles, other importer entries (e.g. `importers["packages/a"]`, `importers["project-2"]`) also represent “roots” that can depend on packages; omitting them makes reverse trees incomplete.
### Issue Context
`Lockfile` stores all project snapshots in `lockfile.importers` (a map keyed by importer id). The CLI already has tests demonstrating non-root importer ids in lockfiles.
### Fix Focus Areas
- pacquet/crates/cli/src/cli_args/why.rs[114-131]
- pacquet/crates/cli/src/cli_args/why.rs[173-200]
**Implementation direction:**
- Iterate over **all** `lockfile.importers` entries (not only `root_project()`), and for each importer’s dependency maps (prod/dev/optional), add reverse edges into `reverse_map`.
- Represent importer nodes distinctly from real packages so `walk_reverse` can terminate at *any* importer root (not just `"."`). For example:
- Introduce an `enum ParentNodeKey { Package(PkgNameVerPeer), Importer(String) }`, or
- Keep using `PkgNameVerPeer` but encode importer ids in a dedicated/guaranteed-non-package namespace and update `is_root_importer` accordingly.
- Update rendering so non-root importers show meaningful labels (e.g. importer id or manifest name) instead of only the hardcoded `project` leaf.
- Add/extend integration tests to cover a workspace lockfile where the dependency is only depended on by a non-root importer, asserting the output includes that importer path.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. No-lockfile errors in why 🐞 Bug ≡ Correctness
Description
WhyArgs::run returns ERR_PNPM_OUTDATED_NO_LOCKFILE when the lockfile is missing, but pnpm’s
whyForPackages returns an empty string (success) when no lockfile can be loaded. This breaks
pacquet/pnpm parity and the added integration test enshrines the divergent behavior.
Code

pacquet/crates/cli/src/cli_args/why.rs[R56-68]

+        let lockfile = state
+            .lockfile
+            .get()
+            .map_err(|err| miette::Report::new(err).wrap_err("load the lockfile"))?;
+
+        let Some(lockfile) = lockfile else {
+            let dir =
+                state.manifest.path().parent().unwrap_or_else(|| state.manifest.path()).display();
+            return Err(miette::miette!(
+                code = "ERR_PNPM_OUTDATED_NO_LOCKFILE",
+                "No lockfile in directory \"{dir}\". Run `pacquet install` to generate one."
+            ));
+        };
Evidence
Pacquet explicitly errors when state.lockfile.get() returns None, while pnpm’s whyForPackages
returns '' when it can’t load a lockfile; the added pacquet integration test asserts the failing
behavior, confirming this divergence is intentional in this PR but mismatched to pnpm.

pacquet/crates/cli/src/cli_args/why.rs[56-68]
deps/inspection/list/src/index.ts[251-256]
pacquet/crates/cli/tests/why.rs[100-114]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`pacquet why` currently hard-fails when no lockfile is present, but upstream pnpm treats “no lockfile” as “no results” and exits successfully. This is a parity break and the integration test currently asserts the wrong behavior.
### Issue Context
- Pacquet returns a miette error with code `ERR_PNPM_OUTDATED_NO_LOCKFILE`.
- Upstream pnpm’s `whyForPackages` returns an empty string when it cannot read either current or wanted lockfile.
### Fix Focus Areas
- pacquet/crates/cli/src/cli_args/why.rs[56-68]
- pacquet/crates/cli/tests/why.rs[100-114]
- deps/inspection/list/src/index.ts[251-256]
### Suggested fix
1. Change `WhyArgs::run` so `lockfile == None` returns `Ok(())` (print nothing) instead of an error.
2. Update/remove `why_fails_without_lockfile` integration test to expect success with empty output (or no output), matching pnpm.
3. (Optional parity improvement) Consider mirroring pnpm’s “try current lockfile then wanted lockfile” behavior if pacquet has an equivalent API.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

6. Why buffers full output 🐞 Bug ➹ Performance
Description
render_tree() builds the entire output into a single String and WhyArgs::run() writes it only
after rendering completes, increasing peak memory usage and delaying output for large lockfiles or
broad match patterns. This can significantly increase memory pressure compared to streaming writes.
Code

pacquet/crates/cli/src/cli_args/why.rs[R411-431]

+fn render_tree(results: &[WhyResult], max_depth: Option<usize>) -> String {
+    let mut output = String::new();
+
+    for (i, result) in results.iter().enumerate() {
+        if i > 0 {
+            output.push_str("\n\n");
+        }
+
+        let root_label = format!("{}{}", bold(&result.name), dim(&format!("@{}", result.version)));
+        output.push_str(&root_label);
+
+        if result.dependents.is_empty() {
+            continue;
+        }
+
+        output.push('\n');
+        render_dependents(&mut output, &result.dependents, "", max_depth, 0);
+    }
+
+    output
+}
Evidence
The rendering path allocates and appends into a String (render_tree), and run() stores that
string in output before writing it to stdout, proving output is buffered in full rather than
streamed.

pacquet/crates/cli/src/cli_args/why.rs[411-431]
pacquet/crates/cli/src/cli_args/why.rs[127-131]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The `why` command renders the full dependency tree into an in-memory `String` and only then writes it to stdout. For large reverse trees this creates unnecessary peak memory usage and prevents incremental output.
## Issue Context
This is a CLI inspection command that can match many nodes (e.g., glob patterns), so output size can be large.
## Fix Focus Areas
- pacquet/crates/cli/src/cli_args/why.rs[127-131]
- pacquet/crates/cli/src/cli_args/why.rs[411-470]
## Suggested fix direction
- Refactor `render_tree`/`render_dependents` to write into an `impl std::io::Write` (e.g., `BufWriter<StdoutLock>`), emitting output incrementally.
- Keep the existing formatting/sanitization logic, but avoid building a monolithic `String`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


7. Snapshots-only lockfiles ignored 🐞 Bug ≡ Correctness
Description
build_dependents_tree() returns an empty result when lockfile.packages is None, so `pacquet
why prints nothing even though reverse edges can still be derived from lockfile.snapshots` and
lockfile.importers. This breaks why on valid/pruned lockfiles where packages: is missing or
incomplete.
Code

pacquet/crates/cli/src/cli_args/why.rs[R175-186]

+fn build_dependents_tree(
+    lockfile: &Lockfile,
+    matcher: &Matcher,
+    importer_info: &HashMap<String, ImporterInfo>,
+    max_depth: Option<usize>,
+) -> Vec<WhyResult> {
+    let Some(packages) = lockfile.packages.as_ref() else {
+        return vec![];
+    };
+
+    let snapshots = lockfile.snapshots.as_ref();
+
Evidence
The new why implementation hard-requires lockfile.packages, but the lockfile model explicitly
allows packages to be absent and has code paths to handle pruned/missing package metadata;
therefore why can incorrectly produce empty output on such lockfiles.

pacquet/crates/cli/src/cli_args/why.rs[175-186]
pacquet/crates/lockfile/src/lib.rs[193-206]
pacquet/crates/lockfile/src/lib.rs[246-275]
pacquet/crates/lockfile/src/load_lockfile.rs[86-102]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`build_dependents_tree()` currently bails out when `lockfile.packages` is absent, which makes `pacquet why` return no output even when a dependency graph is still available via `snapshots:` and `importers:`.
## Issue Context
In this repo, `Lockfile.packages` is optional and there is explicit support for “pruned lockfiles” / missing package metadata; `why` should not require `packages:` to exist just to build the reverse dependency graph.
## Fix Focus Areas
- pacquet/crates/cli/src/cli_args/why.rs[175-210]
- pacquet/crates/cli/src/cli_args/why.rs[241-321]
## Suggested approach
- Stop treating `lockfile.packages` as the canonical set of nodes.
- Build node keys primarily from `lockfile.snapshots` keys (and optionally union with `packages` keys when present).
- Make `WalkCtx.packages` optional (or pass an empty map) so `display_version` can still fall back to `key.suffix.version()` when metadata is unavailable.
- Ensure forward edges are built from snapshot entries (iterate over `snapshots`), not from `packages.keys()`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


8. Repeated reverse traversal per match 🐞 Bug ➹ Performance
Description
build_dependents_tree() runs walk_reverse() independently for every matched package/importer, so
broad patterns (e.g. @scope/*) can repeatedly traverse large portions of the same reverse graph.
On large lockfiles with many matches this can significantly increase CPU time and memory allocations
compared to a single shared traversal/memoized subtrees.
Code

pacquet/crates/cli/src/cli_args/why.rs[R258-316]

+    let mut results: Vec<WhyResult> = Vec::new();
+
+    for key in packages.keys() {
+        let name = key.name.to_string();
+        let version = display_version(key, Some(packages));
+
+        let matched = matcher.matches(&name)
+            || reverse_map
+                .get(&ReverseKey::Package(key.clone()))
+                .is_some_and(|edges| edges.iter().any(|(_parent, alias)| matcher.matches(alias)));
+
+        if !matched {
+            continue;
+        }
+
+        let mut visited = HashSet::new();
+        visited.insert(ParentNode::Package(key.clone()));
+        let mut expanded = HashSet::new();
+        let dependents = walk_reverse(
+            &ReverseKey::Package(key.clone()),
+            &ctx,
+            &mut visited,
+            &mut expanded,
+            0,
+            max_depth,
+        );
+
+        results.push(WhyResult { name: name.clone(), version, dependents });
+    }
+
+    for importer_id in importer_info.keys() {
+        let info = importer_info.get(importer_id.as_str());
+        let name = info.map_or_else(|| importer_id.clone(), |i| i.name.clone());
+
+        let matched = matcher.matches(&name)
+            || reverse_map
+                .get(&ReverseKey::Importer(importer_id.clone()))
+                .is_some_and(|edges| edges.iter().any(|(_parent, alias)| matcher.matches(alias)));
+
+        if !matched {
+            continue;
+        }
+
+        let mut visited = HashSet::new();
+        visited.insert(ParentNode::Importer(importer_id.clone()));
+        let mut expanded = HashSet::new();
+        let dependents = walk_reverse(
+            &ReverseKey::Importer(importer_id.clone()),
+            &ctx,
+            &mut visited,
+            &mut expanded,
+            0,
+            max_depth,
+        );
+
+        let version = info.map_or_else(String::new, |i| i.version.clone());
+        results.push(WhyResult { name: name.clone(), version, dependents });
+    }
+
Evidence
The code iterates all packages/importers, and for each match it calls walk_reverse() with fresh
traversal state; walk_reverse() is recursive and can walk substantial subgraphs, so broad matches
multiply that cost.

pacquet/crates/cli/src/cli_args/why.rs[258-316]
pacquet/crates/cli/src/cli_args/why.rs[323-403]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`build_dependents_tree()` performs a potentially expensive reverse-walk for each matched root independently. With glob patterns that match many packages, this repeats work across matches and can scale poorly on large graphs.
## Issue Context
`walk_reverse()` can traverse a large fraction of `reverse_map`. When invoked once per match, runtime approaches “number of matches × traversal cost”.
## Fix Focus Areas
- pacquet/crates/cli/src/cli_args/why.rs[258-316]
- pacquet/crates/cli/src/cli_args/why.rs[323-403]
## Suggested approach
- Introduce memoization for computed subtrees, keyed by `(ReverseKey, depth_remaining)` (or `(ReverseKey, depth)`), to reuse results across multiple matched roots.
- Keep cycle detection via the per-call `visited` stack, but reuse already-built subtrees when the node is not on the current `visited` path.
- Consider a fast path: first collect the set of matched roots (keys/importers) and only then build trees, so the matching scan doesn’t interleave with expensive walks.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (8)
9. Dep group info dropped 🐞 Bug ≡ Correctness
Description
build_dependents_tree iterates dependency groups for importers but discards the group metadata
when recording reverse edges, and walk_reverse always sets DependentNode.dep_field to None. As
a result, the renderer can never show whether a dependent comes from
dependencies/devDependencies/optionalDependencies (and in cases where a package appears in multiple
groups, output can become ambiguous/noisy).
Code

pacquet/crates/cli/src/cli_args/why.rs[R254-263]

+    for (parent_node, _alias) in edges {
+        match parent_node {
+            ParentNode::Importer(importer_id) => {
+                let info = importer_info.get(importer_id.as_str());
+                dependents.push(DependentNode {
+                    name: info.map_or_else(|| importer_id.clone(), |i| i.name.clone()),
+                    version: info.map_or_else(String::new, |i| i.version.clone()),
+                    dep_field: None,
+                    dependents: vec![],
+                });
Evidence
The code explicitly iterates dependency groups when seeding reverse edges, but the edge
representation omits the group and later ignores the alias while always setting dep_field to
None; meanwhile the renderer only prints group info when dep_field is Some.

pacquet/crates/cli/src/cli_args/why.rs[165-178]
pacquet/crates/cli/src/cli_args/why.rs[254-263]
pacquet/crates/cli/src/cli_args/why.rs[261-309]
pacquet/crates/cli/src/cli_args/why.rs[358-365]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`pacquet why` currently can’t render which dependency field (dependencies/devDependencies/optionalDependencies) caused a package to be a dependent because the reverse-edge construction drops dependency-group info and `DependentNode.dep_field` is never populated.
### Issue Context
- Reverse edges from importers are built by iterating `DependencyGroup::Prod/Dev/Optional`, but the stored edge tuple does not include the group.
- The renderer has formatting for `dep.dep_field`, but it is unreachable because `walk_reverse()` sets `dep_field: None` for every node.
### Fix Focus Areas
- pacquet/crates/cli/src/cli_args/why.rs[163-182]
- pacquet/crates/cli/src/cli_args/why.rs[254-310]
- pacquet/crates/cli/src/cli_args/why.rs[358-366]
### Implementation outline
1. Change the reverse edge value type to carry group info, e.g. `Vec<(ParentNode, String, Option<DependencyGroup>)>` or a small struct.
2. When adding importer edges, store `Some(group)`.
3. In `walk_reverse`, set `DependentNode.dep_field` from the stored edge group (at least for importer nodes; for package->package edges you can keep `None` unless you want to compute it).
4. Consider de-duplicating edges where the same parent appears multiple times (optional), now that group info exists.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


10. Non-registry version misrendered 🐞 Bug ≡ Correctness
Description
pacquet why renders package “versions” from the depPath suffix (key.suffix.version()), which for
non-registry deps (depPath contains :) is not the manifest version. When the lockfile records the
real manifest version in PackageMetadata.version, the why output will be misleading (e.g.,
showing file:/URL-ish depPath fragments instead of the package’s version).
Code

pacquet/crates/cli/src/cli_args/why.rs[R206-209]

+    for key in packages.keys() {
+        let name = key.name.to_string();
+        let version = key.suffix.version().to_string();
+
Evidence
why currently derives the display version from the depPath suffix. But the lockfile model
explicitly records a separate PackageMetadata.version for non-registry depPaths (containing :)
because the depPath suffix is not the canonical version in that case.

pacquet/crates/cli/src/cli_args/why.rs[206-209]
pacquet/crates/lockfile/src/package_metadata.rs[16-21]
pacquet/crates/package-manager/src/dependencies_graph_to_lockfile.rs[550-564]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`build_dependents_tree` uses `key.suffix.version().to_string()` as the displayed version string. For non-registry dependencies, pacquet intentionally stores the *real* manifest version in `PackageMetadata.version` (because the depPath suffix is not the semantic version), so `why` can display incorrect/misleading version information.
## Issue Context
The lockfile writer sets `PackageMetadata.version` for dep paths containing `:` (non-registry) when appropriate, and the lockfile type documents that registry packages omit `version` because their version is already the depPath suffix.
## Fix Focus Areas
- pacquet/crates/cli/src/cli_args/why.rs[206-209]
### Suggested fix approach
1. Add a small helper for display version:
- For a `PkgNameVerPeer` key, compute the metadata lookup key as `key.without_peer()`.
- If `lockfile.packages[metadata_key].version` is `Some(v)`, prefer that for display.
- Otherwise fall back to `key.suffix.version().to_string()`.
2. Use this helper when populating `WhyResult.version` (and ideally also when rendering `ParentNode::Package` nodes so parents show consistent versions).
3. Add tests covering at least one non-registry depPath (e.g., `file:` or URL) where `PackageMetadata.version` is present, asserting `why` prints the manifest version.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


11. Unneeded network init in why 🐞 Bug ☼ Reliability
Description
pacquet why is dispatched through State::init, which eagerly constructs the HTTP client and can
error on malformed proxy/TLS config even though why only needs local manifest+lockfile. This makes
pacquet why fail in repos with repo-controlled .npmrc proxy/TLS misconfiguration (local DoS of
an inspection command).
Code

pacquet/crates/cli/src/cli_args.rs[R304-306]

+            CliCommand::Why(args) => {
+                args.run(state(true)?).await?;
+            }
Evidence
The CLI dispatch for why calls State::init, and State::init always builds the HTTP client via
ThrottledClient::for_installs, which explicitly returns errors for invalid proxy URLs and
malformed TLS inputs; therefore why can fail before doing any lockfile traversal.

pacquet/crates/cli/src/cli_args.rs[271-306]
pacquet/crates/cli/src/state.rs[59-95]
pacquet/crates/network/src/lib.rs[274-319]
pacquet/crates/network/src/proxy.rs[65-111]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`CliCommand::Why` uses `state(true)?`, which calls `State::init(...)`. `State::init` always constructs `ThrottledClient::for_installs(...)`, so `pacquet why` can fail on invalid proxy/TLS configuration even though the command does not perform network I/O.
### Issue Context
This is user-visible because `.npmrc` can be repo-controlled; a malformed `https-proxy`, CA PEM, etc., can prevent `pacquet why` from running at all.
### Fix Focus Areas
- pacquet/crates/cli/src/cli_args.rs[271-306]
- pacquet/crates/cli/src/state.rs[59-95]
### Implementation direction
- Introduce a lighter-weight state initializer for read-only lockfile commands (e.g., `State::init_lockfile_query(...)`) that does **not** construct `http_client`.
- Options:
- Make `State.http_client` lazy (e.g., `OnceCell<Arc<ThrottledClient>>`) and construct it only when needed by install/network commands.
- Or create a small struct used by `why` containing only `config`, `manifest`, and `lockfile`.
- Wire `CliCommand::Why` to use the lockfile-only state initializer.
- Add/adjust a test demonstrating `pacquet why` succeeds even when proxy config is invalid (if that is intended parity behavior).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


12. Missing find-by support 🐞 Bug ≡ Correctness
Description
WhyArgs::run() unconditionally errors when no package patterns are provided, but pnpm allows why
to run with no positional args when --find-by is provided. This is a user-visible parity break
that prevents pnpm-compatible workflows/scripts relying on --find-by.
Code

pacquet/crates/cli/src/cli_args/why.rs[R56-71]

+#[derive(Debug, Args)]
+pub struct WhyArgs {
+    pub packages: Vec<String>,
+
+    #[clap(long)]
+    pub depth: Option<usize>,
+}
+
+impl WhyArgs {
+    pub async fn run(self, state: State) -> miette::Result<()> {
+        if self.packages.is_empty() {
+            return Err(miette::miette!(
+                code = "ERR_PNPM_MISSING_PACKAGE_NAME",
+                "`pacquet why` requires a package name or pattern"
+            ));
+        }
Evidence
The new Rust CLI rejects empty positional args unconditionally, while pnpm’s why only rejects when
both params are empty and opts.findBy is absent, and pnpm then passes resolved finders into
whyForPackages.

pacquet/crates/cli/src/cli_args/why.rs[56-71]
deps/inspection/commands/src/listing/why.ts[57-85]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`pacquet why` currently requires at least one positional package/pattern and errors otherwise. Upstream pnpm’s `why` only errors when both positional args are empty **and** `--find-by` is not provided.
### Issue Context
This is a CLI parity gap: pnpm supports `--find-by` for `why`, enabling searches without positional patterns.
### Fix Focus Areas
- pacquet/crates/cli/src/cli_args/why.rs[56-71]
- deps/inspection/commands/src/listing/why.ts[57-85]
### What to change
- Add a `--find-by` option to `WhyArgs` (shape matching pnpm as closely as possible).
- Update the empty-params validation to allow `packages.is_empty()` when `find_by` is present, mirroring pnpm’s condition.
- Wire the finder(s) into the matching logic (or, if full finder support isn’t implemented yet, explicitly reject unsupported finder names with a pnpm-compatible error message/code rather than rejecting the invocation as “missing package name”).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


13. Wrong error code 🐞 Bug ≡ Correctness
Description
The missing-argument error uses diagnostic code ERR_PNPM_MISSING_PACKAGE_NAME, but pnpm’s why
throws MISSING_PACKAGE_NAME. This breaks pnpm/pacquet parity for tooling that keys off the error
code.
Code

pacquet/crates/cli/src/cli_args/why.rs[R66-70]

+        if self.packages.is_empty() {
+            return Err(miette::miette!(
+                code = "ERR_PNPM_MISSING_PACKAGE_NAME",
+                "`pacquet why` requires a package name or pattern"
+            ));
Evidence
The Rust implementation hardcodes ERR_PNPM_MISSING_PACKAGE_NAME for this error, while pnpm’s
implementation uses MISSING_PACKAGE_NAME for the same condition.

pacquet/crates/cli/src/cli_args/why.rs[64-71]
deps/inspection/commands/src/listing/why.ts[61-63]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The new `pacquet why` error code for missing positional args does not match pnpm.
### Issue Context
pnpm throws `new PnpmError('MISSING_PACKAGE_NAME', ...)` for this case.
### Fix Focus Areas
- pacquet/crates/cli/src/cli_args/why.rs[66-70]
- deps/inspection/commands/src/listing/why.ts[61-63]
### What to change
- Change the diagnostic code emitted by pacquet for this condition to `MISSING_PACKAGE_NAME` (or otherwise match the exact pnpm code that should be surfaced for parity).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


14. Trailing @ on empty version 🐞 Bug ≡ Correctness
Description
render_tree and render_dependents always append @{version} even when version is empty,
producing output like the root project@ or @. This diverges from pnpm’s behavior (it omits the
@ when version is missing) and makes output confusing for projects/importers without a version
field.
Code

pacquet/crates/cli/src/cli_args/why.rs[R291-338]

+fn render_tree(results: &[WhyResult], max_depth: Option<usize>) -> String {
+    let mut output = String::new();
+
+    for (i, result) in results.iter().enumerate() {
+        if i > 0 {
+            output.push_str("\n\n");
+        }
+
+        let root_label = format!("{}{}", bold(&result.name), dim(&format!("@{}", result.version)));
+        output.push_str(&root_label);
+
+        if result.dependents.is_empty() {
+            continue;
+        }
+
+        output.push('\n');
+        render_dependents(&mut output, &result.dependents, "", max_depth, 0);
+    }
+
+    output
+}
+
+fn render_dependents(
+    output: &mut String,
+    dependents: &[DependentNode],
+    prefix: &str,
+    max_depth: Option<usize>,
+    current_depth: usize,
+) {
+    if let Some(max) = max_depth
+        && current_depth >= max
+    {
+        return;
+    }
+
+    for (i, dep) in dependents.iter().enumerate() {
+        let is_last = i == dependents.len() - 1;
+        let connector = if is_last { "└── " } else { "├── " };
+        let child_prefix = if is_last { "    " } else { "│   " };
+
+        let label = format!(
+            "{}{}{}",
+            bold(&dep.name),
+            dim(&format!("@{}", dep.version)),
+            dep.dep_field
+                .map(|f| format!(" {}", dim(&format!("({})", dep_field_name(f)))))
+                .unwrap_or_default(),
+        );
Evidence
The pacquet implementation intentionally sets some importer versions to the empty string, and the
renderer unconditionally formats @{version}. pnpm’s renderer explicitly omits the @ when version
is empty, so this is a clear parity/correctness mismatch.

pacquet/crates/cli/src/cli_args/why.rs[95-110]
pacquet/crates/cli/src/cli_args/why.rs[291-350]
deps/inspection/list/src/renderDependentsTree.ts[178-180]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`pacquet why` currently formats labels as `name@version` unconditionally. When `version` is `""` (common for non-root importers and for manifests without a version), the output contains a dangling `@`.
### Issue Context
- Non-root importers are explicitly assigned `version: String::new()`.
- pnpm’s renderer uses a helper that returns `name` (no `@`) when `version` is falsy.
### Fix Focus Areas
- pacquet/crates/cli/src/cli_args/why.rs[89-110]
- pacquet/crates/cli/src/cli_args/why.rs[291-348]
### Suggested fix
Introduce a small helper similar to pnpm’s `plainNameAtVersion`:
- If `version.is_empty()`, render just the name.
- Otherwise render `name` plus a dimmed `@{version}`.
Apply it to:
- `root_label` in `render_tree`.
- each dependent node label in `render_dependents`.
(Optionally add/adjust a unit test for an importer with an empty version to ensure no dangling `@` is printed.)

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


15. Missing --exclude-peers flag 🐞 Bug ⚙ Maintainability
Description
The new WhyArgs does not define --exclude-peers, so pacquet’s why command rejects a
pnpm-supported option and breaks CLI parity. This is user-visible because scripts/users passing
--exclude-peers will get an unknown-flag error.
Code

pacquet/crates/cli/src/cli_args/why.rs[R56-62]

+#[derive(Debug, Args)]
+pub struct WhyArgs {
+    pub packages: Vec<String>,
+
+    #[clap(long)]
+    pub depth: Option<usize>,
+}
Evidence
pnpm explicitly defines and documents exclude-peers for why, while the newly-added pacquet
WhyArgs struct does not include it, so pacquet cannot accept the same CLI invocation.

pacquet/crates/cli/src/cli_args/why.rs[56-62]
deps/inspection/commands/src/listing/why.ts[16-46]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
pnpm’s `why` command exposes an `--exclude-peers` option, but pacquet’s `WhyArgs` currently only includes `packages` and `--depth`. This breaks pnpm/pacquet CLI parity and prevents users from porting commands/scripts.
### Issue Context
In pnpm, `--exclude-peers` is part of the `why` command’s CLI options and help.
### Fix Focus Areas
- pacquet/crates/cli/src/cli_args/why.rs[56-62]
### Suggested fix
Add a boolean flag to `WhyArgs`:
- `#[clap(long = "exclude-peers")] pub exclude_peers: bool`
If behavior is not implemented yet, still parse and accept the flag (no-op) to preserve CLI compatibility. Add an integration test asserting `pacquet why --exclude-peers <pkg>` succeeds (or at least does not error on argument parsing).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


16. Terminal escape injection 🐞 Bug ⛨ Security
Description
pacquet why prints lockfile-derived package names/versions to an ANSI-capable terminal without
sanitizing control characters, enabling terminal escape-sequence injection via a crafted
pnpm-lock.yaml. This can spoof output (e.g., clear screen, rewrite lines) when a developer runs
pacquet why in an untrusted repo.
Code

pacquet/crates/cli/src/cli_args/why.rs[R241-290]

+        let root_label = format!("{}{}", bold(&result.name), dim(&format!("@{}", result.version)));
+        output.push_str(&root_label);
+
+        if result.dependents.is_empty() {
+            continue;
+        }
+
+        output.push('\n');
+        render_dependents(&mut output, &result.dependents, "", max_depth, 0);
+    }
+
+    output
+}
+
+fn render_dependents(
+    output: &mut String,
+    dependents: &[DependentNode],
+    prefix: &str,
+    max_depth: Option<usize>,
+    current_depth: usize,
+) {
+    if let Some(max) = max_depth {
+        if current_depth >= max {
+            return;
+        }
+    }
+
+    for (i, dep) in dependents.iter().enumerate() {
+        let is_last = i == dependents.len() - 1;
+        let connector = if is_last { "└── " } else { "├── " };
+        let child_prefix = if is_last { "    " } else { "│   " };
+
+        let label = format!(
+            "{}{}{}",
+            bold(&dep.name),
+            dim(&format!("@{}", dep.version)),
+            dep.dep_field
+                .map(|f| format!(" {}", dim(&format!("({})", dep_field_name(f)))))
+                .unwrap_or_default(),
+        );
+
+        output.push_str(prefix);
+        output.push_str(connector);
+        output.push_str(&label);
+        output.push('\n');
+
+        if !dep.dependents.is_empty() {
+            let new_prefix = format!("{prefix}{child_prefix}");
+            render_dependents(output, &dep.dependents, &new_prefix, max_depth, current_depth + 1);
+        }
Evidence
The renderer formats result.name/result.version and dependent node name/version directly
into output strings, and those values originate from lockfile keys. PkgName::parse accepts any
non-empty string without restricting control characters, so a crafted lockfile can inject terminal
escape sequences that reach stdout.

pacquet/crates/cli/src/cli_args/why.rs[233-290]
pacquet/crates/lockfile/src/pkg_name.rs[30-50]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`pacquet why` renders package names and versions from the lockfile directly into terminal output (with optional ANSI styling). Because lockfiles are attacker-controlled in untrusted repos and `PkgName` parsing does not validate characters, names can contain ASCII control bytes (notably ESC `\x1b`) that terminals interpret as escape sequences.
### Issue Context
We should treat lockfile text as untrusted and ensure output is safe for terminals by stripping or escaping control characters before styling/printing.
### Fix Focus Areas
- pacquet/crates/cli/src/cli_args/why.rs[233-309]
- pacquet/crates/lockfile/src/pkg_name.rs[30-50]
### Suggested fix
- Introduce a small helper (e.g. `fn sanitize_terminal(s: &str) -> Cow<'_, str>`) that removes or escapes control characters (at minimum `\x1b`, and ideally all `char::is_control()` except maybe `\n`/`\t` if desired).
- Apply sanitization to every user/lockfile-derived token before passing it to `bold()` / `dim()` and before concatenating into `output`.
- Add a unit test verifying that an input containing `\x1b[2J` is rendered as a harmless escaped sequence (or stripped) rather than executed by the terminal.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Informational

17. Fixed mtime sleep flaky 🐞 Bug ☼ Reliability ⭐ New
Description
The test uses a fixed 100ms sleep to force the manifest mtime to advance, but the install fast-path
compares mtimes at millisecond granularity and can still consider two writes identical on coarser
timestamp environments. This can reintroduce intermittent CI flakes where the second install
doesn’t observe the manifest change.
Code

[pacquet/crates/cli/tests/lockfile_resolution_reuse.rs[R133-135]](https://github.com/pnpm/pnpm/pull/12497/files#diff-321c439eab3ef1fec8895021c4cdc2f42f28f2248...

@coderabbitai

coderabbitai Bot commented Jun 18, 2026

Copy link
Copy Markdown

Review Change Stack

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 why CLI subcommand that accepts package name patterns and an optional max depth. It reads the lockfile, builds a reverse dependency adjacency map from lockfile snapshots and root importer specs, walks it recursively with cycle detection, and renders a formatted tree to stdout showing what packages depend on the queried target.

Changes

pacquet why subcommand

Layer / File(s) Summary
CLI wiring: module, enum variant, and dispatch
pacquet/crates/cli/src/cli_args.rs
Declares pub mod why, imports WhyArgs, adds CliCommand::Why(WhyArgs) enum variant, and dispatches it via args.run(state(true)?) as a read-only query without the install pipeline.
WhyArgs struct, run entrypoint, reverse-tree build and render
pacquet/crates/cli/src/cli_args/why.rs
Defines WhyArgs with pattern list and optional depth; run validates input, loads lockfile, and writes output; build_dependents_tree constructs reverse adjacency from lockfile snapshots and root importer specs with pattern matching; walk_reverse performs cycle-safe recursive traversal with depth cutoff; render_tree, render_dependents, dep_field_name, and styling helpers format Unicode tree output.
Unit tests: dep_field_name and render_tree helpers
pacquet/crates/cli/src/cli_args/why/tests.rs
Tests dep_field_name mappings for all DependencyGroup variants and render_tree for empty input, single node, nested dependents, and depth-limiting behavior.
Integration tests: CLI why behaviors end-to-end
pacquet/crates/cli/tests/why.rs
Integration tests covering missing-argument error, direct dependency lookup with tree output validation, transitive dependency lookup showing dependency chains, glob pattern matching across multiple packages, and missing-lockfile error, using test helpers for workspace setup and manifest writing.

Sequence Diagram

sequenceDiagram
  participant User
  participant CLI as CliArgs::run
  participant WhyArgs as WhyArgs::run
  participant Lockfile as State::lockfile
  participant Builder as build_dependents_tree
  participant Renderer as render_tree

  User->>CLI: pacquet why <pattern> [--depth N]
  CLI->>WhyArgs: dispatch Why(args) with state
  WhyArgs->>Lockfile: load lockfile or return error if missing
  WhyArgs->>Builder: build reverse adjacency from lockfile snapshots and importer deps
  Builder->>Builder: match packages against names and aliases
  Builder-->>WhyArgs: sorted WhyResult entries with DependentNode trees
  WhyArgs->>Renderer: render_tree(results, max_depth)
  Renderer-->>WhyArgs: formatted Unicode tree string with bold/dim styling
  WhyArgs-->>User: write to stdout
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Suggested reviewers

  • zkochan
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Add pacquet why reverse-dependency command + fix GVS hoist symlink replacement
✨ Enhancement 🐞 Bug fix 🧪 Tests 🕐 40+ Minutes

Grey Divider

Description

• Add pacquet why to print reverse dependency trees from the lockfile.
• Fix hoist symlink replacement when toggling on global virtual store.
• Add unit and integration coverage for why and the GVS regression.
Diagram

graph TD
  U(["User"]) --> C["pacquet CLI"] --> W["why command"] --> L[("lockfile")]
  W --> M["glob matcher"] --> T["reverse tree"] --> O["stdout tree"]
  I["pnpm install/hoist"] --> H["hoist symlinker"] --> S["symlink replace"]
  subgraph Legend
    direction LR
    _u(["Actor/CLI"]) ~~~ _m["Module"] ~~~ _db[("Data")]
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Use a graph library (e.g., petgraph) for inversion + traversal
  • ➕ Clearer separation of graph construction vs traversal
  • ➕ Built-in traversal helpers and potentially simpler cycle handling
  • ➖ Adds/expands dependency surface for a relatively small feature
  • ➖ May complicate mapping back to lockfile-specific node keys and display labels
2. Compute reverse edges directly while iterating snapshots/importer
  • ➕ Avoids building a full forward adjacency list first (less memory)
  • ➕ Keeps data model aligned to the final query (reverse lookups)
  • ➖ Harder to extend later if forward edges are needed for other commands
  • ➖ Current approach is already straightforward and readable
3. Implement `dep_field` and `--exclude-peers` fully in the first pass
  • ➕ Parity with pnpm output semantics (which dependency group pulled it in)
  • ➕ Makes --exclude-peers meaningful and avoids a “reserved” flag
  • ➖ Requires more detailed edge metadata (group + alias) throughout traversal
  • ➖ More complex logic and test matrix for the initial introduction

Recommendation: The current approach (build forward edges, invert to a reverse map, then DFS with cycle detection) is a solid, low-risk port that matches the lockfile-driven domain. Consider incrementally enriching edges with dependency group/peer information to support dep_field output and make --exclude-peers actionable, rather than introducing a heavy graph dependency.

Files changed (6) +556 / -3

Enhancement (2) +326 / -0
cli_args.rsWire up new 'why' subcommand in the Rust CLI +9/-0

Wire up new 'why' subcommand in the Rust CLI

• Register the 'why' module, add 'CliCommand::Why', and dispatch it as a read-only command (like 'outdated') using a non-installing state. This makes 'pacquet why' available from the main CLI entrypoint.

pacquet/crates/cli/src/cli_args.rs

why.rsImplement 'pacquet why' reverse dependency tree builder and renderer +317/-0

Implement 'pacquet why' reverse dependency tree builder and renderer

• Introduce the 'why' command implementation that loads the lockfile, matches packages via glob patterns, builds an inverted dependency map, and walks reverse edges with cycle detection. Render results as a tree with optional depth limiting and colorized output; '--exclude-peers' is present but not yet applied to traversal.

pacquet/crates/cli/src/cli_args/why.rs

Bug fix (1) +6 / -3
index.tsTreat pnpm internal dir as safe when deciding to replace symlinks +6/-3

Treat pnpm internal dir as safe when deciding to replace symlinks

• Pass 'internalPnpmDir' into 'symlinkHoistedDependency' and treat symlinks pointing into either the virtual store or pnpm internal directory as eligible for replacement. This prevents stale non-GVS symlinks from being skipped when enabling the global virtual store.

installing/linking/hoist/src/index.ts

Tests (3) +224 / -0
tests.rsAdd unit tests for tree rendering and depth limiting +71/-0

Add unit tests for tree rendering and depth limiting

• Add tests covering dependency-group label mapping and 'render_tree' behavior for empty results, leaf nodes, nested dependents, and max-depth truncation.

pacquet/crates/cli/src/cli_args/why/tests.rs

why.rsAdd integration tests for 'pacquet why' CLI behavior +114/-0

Add integration tests for 'pacquet why' CLI behavior

• Add end-to-end tests validating error handling (missing args, missing lockfile), output for direct/transitive dependencies, and glob-pattern matching across multiple packages using a mocked registry setup.

pacquet/crates/cli/tests/why.rs

globalVirtualStore.tsRegression test for switching non-GVS → GVS replacing hoisted symlinks +39/-0

Regression test for switching non-GVS → GVS replacing hoisted symlinks

• Add a test that installs with non-GVS settings, verifies the original hoisted symlink target, then enables global virtual store and asserts the symlink is replaced to point into the GVS links directory. Also validates expected package files exist in both 'node_modules' and the global virtual store structure.

pnpm/test/install/globalVirtualStore.ts

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jun 18, 2026

Copy link
Copy Markdown

Code Review by Qodo

Grey Divider

New Review Started

This review has been superseded by a new analysis

Grey Divider

Qodo Logo

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

Adds a new pacquet why command (Rust CLI) to display reverse dependency trees for matched packages, and adjusts pnpm’s hoisted symlink handling to correctly replace stale hoisted links when switching to the global virtual store (GVS).

Changes:

  • Add pacquet why command implementation with --depth support plus unit/integration tests.
  • Wire the new Why subcommand into the pacquet CLI command router.
  • Fix pnpm hoisted symlink replacement when migrating from non-GVS to GVS, and add an e2e test covering the scenario.

Reviewed changes

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

Show a summary per file
File Description
pnpm/test/install/globalVirtualStore.ts Adds an e2e regression test asserting stale hoisted symlinks are replaced when enabling GVS.
installing/linking/hoist/src/index.ts Allows overwriting hoisted symlinks that point into pnpm-internal .pnpm locations during relinking (migration to GVS).
pacquet/crates/cli/src/cli_args.rs Registers the new why module and exposes pacquet why as a CLI subcommand.
pacquet/crates/cli/src/cli_args/why.rs Implements pacquet why: lockfile read, reverse graph traversal, and tree rendering.
pacquet/crates/cli/src/cli_args/why/tests.rs Adds unit tests for tree rendering and small helpers.
pacquet/crates/cli/tests/why.rs Adds integration tests for pacquet why behavior and error cases.

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

Comment thread pacquet/crates/cli/src/cli_args/why.rs Outdated
Comment on lines +42 to +44
/// Exclude peer dependencies from the output.
#[clap(long)]
pub exclude_peers: bool,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed: removed --exclude-peers entirely since it was a no-op.

Comment thread pacquet/crates/cli/src/cli_args/why.rs Outdated
Comment on lines +161 to +170
let matched = matcher.matches(&name);

if !matched {
let alias_matched = importer_edges
.iter()
.any(|(alias, target)| target.as_ref() == Some(key) && matcher.matches(alias));
if !alias_matched {
continue;
}
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed: alias matching now checks all incoming edges in reverse_map (all parents), not just importer edges.

@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: 5

🤖 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 `@pacquet/crates/cli/src/cli_args.rs`:
- Around line 304-308: The CliCommand::Why variant is calling state(false) which
disables lockfile requirement, but the why command needs lockfile data to
function properly. Change the state(false) call in the CliCommand::Why branch to
state(true) to ensure the lockfile is required and loaded before the why command
executes.

In `@pacquet/crates/cli/src/cli_args/why.rs`:
- Around line 42-45: The exclude_peers boolean flag is declared in the struct
but is never actually consulted when building or rendering the dependency tree
output. To fix this, locate where the tree is constructed and rendered in the
why command implementation, and add logic to filter out peer dependencies from
the tree when the exclude_peers flag is set to true. Ensure the implementation
matches pnpm's behavior for the --exclude-peers flag to maintain consistency
with the coding guidelines.
- Around line 93-95: The forward_edges HashMap uses nondeterministic iteration
order, causing tree output ordering to vary between runs. Replace the HashMap
type with an ordered map structure (such as BTreeMap or IndexMap) for the
forward_edges declaration to ensure consistent, deterministic output ordering
when the map is iterated at line 144 and throughout the walk_reverse function
and related logic. This ensures the tree output matches pnpm's behavior
consistently across runs.
- Around line 182-236: The walk_reverse function performs unbounded recursion
over the dependency graph without any depth limit, creating a denial-of-service
vulnerability when processing malicious lockfiles with extremely deep dependency
chains. Add a maximum depth parameter to the walk_reverse function signature and
check it at the beginning of the recursive calls, returning early with a
DependentNode containing limited information if the maximum depth is exceeded.
This ensures that even with untrusted lockfile input, the traversal cannot cause
a stack overflow regardless of nesting depth.

In `@pacquet/crates/cli/tests/why.rs`:
- Around line 68-71: The transitive test in the why.rs file is not properly
testing transitive dependency traversal. Currently, the write_manifest call
includes both PKG and DEP as direct dependencies, which means DEP is installed
directly rather than only accessible transitively through PKG. To fix this,
modify the manifest format string in the write_manifest call to only include PKG
as a direct dependency, removing DEP from the direct dependencies. This will
ensure that pacquet why DEP must traverse back through PKG to locate DEP,
properly validating the transitive reverse traversal logic rather than just
finding a direct dependency.
🪄 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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: b96e3688-b7a0-4815-a0ee-e6ae9f1184b4

📥 Commits

Reviewing files that changed from the base of the PR and between 6ee484e and cb1538f.

📒 Files selected for processing (6)
  • installing/linking/hoist/src/index.ts
  • pacquet/crates/cli/src/cli_args.rs
  • pacquet/crates/cli/src/cli_args/why.rs
  • pacquet/crates/cli/src/cli_args/why/tests.rs
  • pacquet/crates/cli/tests/why.rs
  • pnpm/test/install/globalVirtualStore.ts

Comment thread pacquet/crates/cli/src/cli_args.rs Outdated
Comment thread pacquet/crates/cli/src/cli_args/why.rs Outdated
Comment thread pacquet/crates/cli/src/cli_args/why.rs
Comment thread pacquet/crates/cli/src/cli_args/why.rs
Comment thread pacquet/crates/cli/tests/why.rs Outdated

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

♻️ Duplicate comments (1)
pacquet/crates/cli/src/cli_args.rs (1)

304-308: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

why requires lockfile data but uses state(false).

The why command builds a reverse dependency graph from lockfile snapshots and cannot function without it (see WhyArgs::run at why.rs:48-83, which errors when lockfile is None). Using state(false) means when lockfile=false is set in config, the lockfile won't be loaded even if pnpm-lock.yaml exists on disk, and the command will fail with "No lockfile" error.

🔧 Proposed fix
-            // `why` is a read-only query like `outdated`: it prints a
-            // reverse dependency tree to stdout and never installs.
             CliCommand::Why(args) => {
-                args.run(state(false)?).await?;
+                args.run(state(true)?).await?;
             }
🤖 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 `@pacquet/crates/cli/src/cli_args.rs` around lines 304 - 308, The `why` command
requires lockfile data to build a reverse dependency graph, but it is currently
calling `state(false)` which prevents the lockfile from being loaded. In the
CliCommand::Why handler, change the `state(false)` call to `state(true)` to
ensure the lockfile is loaded from disk before the `args.run()` method executes,
since WhyArgs::run will error if the lockfile is None.
🤖 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.

Duplicate comments:
In `@pacquet/crates/cli/src/cli_args.rs`:
- Around line 304-308: The `why` command requires lockfile data to build a
reverse dependency graph, but it is currently calling `state(false)` which
prevents the lockfile from being loaded. In the CliCommand::Why handler, change
the `state(false)` call to `state(true)` to ensure the lockfile is loaded from
disk before the `args.run()` method executes, since WhyArgs::run will error if
the lockfile is None.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 7b10e5c3-77dd-4751-9c2e-57a0723b3e1f

📥 Commits

Reviewing files that changed from the base of the PR and between cb1538f and 8848697.

📒 Files selected for processing (4)
  • pacquet/crates/cli/src/cli_args.rs
  • pacquet/crates/cli/src/cli_args/why.rs
  • pacquet/crates/cli/src/cli_args/why/tests.rs
  • pacquet/crates/cli/tests/why.rs
🚧 Files skipped from review as they are similar to previous changes (3)
  • pacquet/crates/cli/src/cli_args/why/tests.rs
  • pacquet/crates/cli/tests/why.rs
  • pacquet/crates/cli/src/cli_args/why.rs

@codecov-commenter

codecov-commenter commented Jun 18, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 82.92683% with 56 lines in your changes missing coverage. Please review.
✅ Project coverage is 88.14%. Comparing base (0474a9c) to head (b0083e1).
⚠️ Report is 6 commits behind head on main.

Files with missing lines Patch % Lines
pacquet/crates/cli/src/cli_args/why.rs 82.82% 56 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #12497      +/-   ##
==========================================
- Coverage   88.18%   88.14%   -0.04%     
==========================================
  Files         313      314       +1     
  Lines       43074    43402     +328     
==========================================
+ Hits        37983    38255     +272     
- Misses       5091     5147      +56     

☔ View full report in Codecov by Harness.
📢 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.

Comment thread pacquet/crates/cli/src/cli_args/why.rs
Comment thread pacquet/crates/cli/src/cli_args/why.rs
Comment thread pacquet/crates/cli/src/cli_args/why.rs Outdated
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 8848697

Copilot AI review requested due to automatic review settings June 18, 2026 12:42
@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jun 18, 2026

Copy link
Copy Markdown

Code Review by Qodo

Grey Divider

New Review Started

This review has been superseded by a new analysis

Grey Divider

Qodo Logo

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

Comment thread pacquet/crates/cli/src/cli_args/why.rs
Comment thread pacquet/crates/cli/src/cli_args/why.rs Outdated
Comment thread pacquet/crates/cli/src/cli_args/why.rs
Comment thread pacquet/crates/cli/src/cli_args/why.rs
Comment thread pacquet/crates/cli/src/cli_args/why.rs
Comment thread pacquet/crates/cli/tests/why.rs
Comment thread pacquet/crates/cli/src/cli_args/why.rs
Comment on lines +145 to +147
for edges in reverse_map.values_mut() {
edges.sort_by(|a, b| a.0.to_string().cmp(&b.0.to_string()));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Remediation recommended

2. Costly unstable edge sorting 🐞 Bug ➹ Performance

Reverse-edge lists are sorted using to_string() inside the comparator, causing repeated heap
allocations during sort and unnecessary CPU on large lockfiles. Sorting only by parent key also
leaves ordering undefined when multiple edges share the same parent (notably the root importer),
making CLI output ordering nondeterministic.
Agent Prompt
### Issue description
`reverse_map` edge lists are sorted with a comparator that allocates (`to_string()`) on every comparison and does not fully order ties (only parent key).

### Issue Context
This runs on every `pacquet why` invocation and can become expensive for large lockfiles with many reverse edges. Also, leaving ties unordered leads to unstable output ordering.

### Fix Focus Areas
- pacquet/crates/cli/src/cli_args/why.rs[145-147]
- pacquet/crates/cli/src/cli_args/why.rs[113-143]

### Suggested fix
- Replace the comparator with a cached-key sort and include the alias in the key so ties are deterministic, e.g.:
  - `edges.sort_by_cached_key(|(parent, alias)| (parent.to_string(), alias.clone()));`
- If you want to avoid allocating `alias.clone()` too, sort by cached parent string and then a borrowed alias via `sort_by` on `(&cached_parent, &alias)` (or store alias as `PkgName`/interned string).
- Add/adjust a test to assert stable ordering for multiple direct dependents from the root importer.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +174 to +183
fn walk_reverse(
node_key: &PkgNameVerPeer,
reverse_map: &HashMap<PkgNameVerPeer, Vec<(PkgNameVerPeer, String)>>,
visited: &mut HashSet<PkgNameVerPeer>,
depth: usize,
) -> Vec<DependentNode> {
if depth >= MAX_REVERSE_WALK_DEPTH {
return vec![];
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Remediation recommended

3. Traversal capped at 64 🐞 Bug ≡ Correctness

walk_reverse hard-stops traversal at depth 64 regardless of --depth, so deep dependency chains
can be silently truncated even when the user expects a full tree. This yields incomplete results
because the cap happens during tree construction, not just rendering.
Agent Prompt
### Issue description
Reverse traversal is capped by a hard-coded constant (`MAX_REVERSE_WALK_DEPTH`) in `walk_reverse`, independent of the user-facing `--depth` rendering limit. This can truncate the dependency ancestry silently.

### Issue Context
`--depth` currently controls rendering (`render_dependents`) but cannot recover nodes omitted during traversal.

### Fix Focus Areas
- pacquet/crates/cli/src/cli_args/why.rs[79-82]
- pacquet/crates/cli/src/cli_args/why.rs[174-183]
- pacquet/crates/cli/src/cli_args/why.rs[255-266]

### Suggested fix
- Pass a traversal limit into `walk_reverse` (e.g., `max_traversal_depth`) derived from `--depth` when provided (often `depth + 1` depending on your depth semantics), and only fall back to a safety cap when `--depth` is not set.
- If you keep a hard safety cap, emit an explicit marker (or warning) when truncation occurs so users know results are incomplete.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 39c7ac7

@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

🤖 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 `@pacquet/crates/cli/src/cli_args/why.rs`:
- Around line 57-60: The error message in the miette::miette! macro call with
code "ERR_PNPM_OUTDATED_NO_LOCKFILE" currently instructs users to run "pacquet
install" but should reference "pnpm install" instead to maintain consistency
with pnpm's exact error messaging. Update the error message string to replace
"pacquet install" with "pnpm install" while keeping the rest of the message
intact.
- Line 197: The `dep_field` is hardcoded to `None` in the tree building logic,
but the rendering infrastructure with `dep_field_name` function and output
formatting exists to display dependency type labels like "(dependencies)" and
"(devDependencies)". Populate `dep_field` with the appropriate dependency group
information during tree construction instead of leaving it as `None`. In the
root importer iteration block, preserve the group information when creating
dependency entries. For non-root packages, leverage the snapshot structure to
determine which dependency group each edge belongs to and set `dep_field` to
that group value accordingly.
🪄 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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: e74ee892-e065-4784-b5ef-3437f887d0bf

📥 Commits

Reviewing files that changed from the base of the PR and between 0a78616 and 39c7ac7.

📒 Files selected for processing (4)
  • pacquet/crates/cli/src/cli_args.rs
  • pacquet/crates/cli/src/cli_args/why.rs
  • pacquet/crates/cli/src/cli_args/why/tests.rs
  • pacquet/crates/cli/tests/why.rs
✅ Files skipped from review due to trivial changes (1)
  • pacquet/crates/cli/src/cli_args/why/tests.rs
🚧 Files skipped from review as they are similar to previous changes (2)
  • pacquet/crates/cli/src/cli_args.rs
  • pacquet/crates/cli/tests/why.rs

Comment thread pacquet/crates/cli/src/cli_args/why.rs Outdated
Comment thread pacquet/crates/cli/src/cli_args/why.rs Outdated
@github-actions

github-actions Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Integrated-Benchmark Report (Linux)

Each scenario reports direct installs and pnpr installs. Bencher consumes pacquet@HEAD and pnpr@HEAD.

Scenario: Isolated linker: fresh restore, cold cache + cold store

Command Mean [s] Min [s] Max [s] Relative
pacquet@HEAD 3.906 ± 0.552 3.540 5.422 1.87 ± 0.27
pacquet@main 3.686 ± 0.119 3.554 3.921 1.76 ± 0.09
pnpr@HEAD 2.167 ± 0.141 2.017 2.405 1.04 ± 0.08
pnpr@main 2.092 ± 0.078 1.973 2.261 1.00
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 3.90601317808,
      "stddev": 0.5519630330972025,
      "median": 3.7449200425800004,
      "user": 3.4300219399999996,
      "system": 3.3571336,
      "min": 3.53975628458,
      "max": 5.422025408580001,
      "times": [
        5.422025408580001,
        3.81092147858,
        3.95275730758,
        3.96518155458,
        3.6812976065800003,
        3.74761142058,
        3.7422286645800003,
        3.6444061435800004,
        3.53975628458,
        3.5539459115800005
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 3.686008630780001,
      "stddev": 0.11934380243895698,
      "median": 3.6372999540800004,
      "user": 3.46965594,
      "system": 3.3395083,
      "min": 3.5536837625800004,
      "max": 3.92149655458,
      "times": [
        3.63197739858,
        3.80580432158,
        3.5974468295800004,
        3.60308602058,
        3.6426225095800002,
        3.8034911105800004,
        3.70018330058,
        3.6002944995800004,
        3.5536837625800004,
        3.92149655458
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 2.1669327656800004,
      "stddev": 0.1411854218126093,
      "median": 2.1505965750800002,
      "user": 2.57933024,
      "system": 2.9685159999999997,
      "min": 2.01690830158,
      "max": 2.40522815658,
      "times": [
        2.1506230415800003,
        2.40522815658,
        2.33011652258,
        2.32791802458,
        2.1605621695800004,
        2.04748156058,
        2.02454006658,
        2.0553797045800004,
        2.15057010858,
        2.01690830158
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 2.0923244716800005,
      "stddev": 0.07778512613246513,
      "median": 2.09529506258,
      "user": 2.63218104,
      "system": 2.965376,
      "min": 1.9728758345800002,
      "max": 2.2614813605800004,
      "times": [
        2.1039285475800003,
        2.03079442858,
        2.2614813605800004,
        2.1502486275800003,
        2.09219754258,
        2.0983925825800003,
        1.9728758345800002,
        2.11238383458,
        2.06377977758,
        2.03716218058
      ]
    }
  ]
}

Scenario: Isolated linker: fresh restore, hot cache + hot store

Command Mean [ms] Min [ms] Max [ms] Relative
pacquet@HEAD 625.2 ± 15.1 609.4 655.5 1.02 ± 0.03
pacquet@main 614.9 ± 7.2 605.9 631.7 1.00
pnpr@HEAD 677.7 ± 13.1 651.6 690.3 1.10 ± 0.02
pnpr@main 692.7 ± 34.4 655.2 763.3 1.13 ± 0.06
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 0.62516206176,
      "stddev": 0.015123900250576116,
      "median": 0.61927949716,
      "user": 0.39094338000000006,
      "system": 1.30320912,
      "min": 0.6093896186600001,
      "max": 0.65550085166,
      "times": [
        0.61665586666,
        0.6168138606600001,
        0.65550085166,
        0.62024253366,
        0.6355841706600001,
        0.6257007316600001,
        0.64347989366,
        0.6093896186600001,
        0.61831646066,
        0.6099366296600001
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 0.6149052149600001,
      "stddev": 0.007230716940288946,
      "median": 0.6136772081600002,
      "user": 0.36649017999999994,
      "system": 1.3049994200000001,
      "min": 0.60593633566,
      "max": 0.6316838596600001,
      "times": [
        0.61596850366,
        0.6316838596600001,
        0.6124806996600001,
        0.6145217806600001,
        0.60593633566,
        0.6097006956600001,
        0.6141066486600001,
        0.6098001646600001,
        0.6132477676600001,
        0.6216056936600001
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 0.6776758283600002,
      "stddev": 0.013079349627300438,
      "median": 0.68244541666,
      "user": 0.39758528000000004,
      "system": 1.3368870200000003,
      "min": 0.6515636556600001,
      "max": 0.6902701456600001,
      "times": [
        0.6880441606600001,
        0.6566891086600001,
        0.6759932326600001,
        0.6902701456600001,
        0.68129552766,
        0.6804157996600001,
        0.68517873366,
        0.68371261366,
        0.6515636556600001,
        0.68359530566
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 0.69271474746,
      "stddev": 0.03435438044356578,
      "median": 0.68527122916,
      "user": 0.38046598,
      "system": 1.3400888200000003,
      "min": 0.65518316466,
      "max": 0.7632964076600001,
      "times": [
        0.65518316466,
        0.6858926026600001,
        0.68464985566,
        0.6556615396600001,
        0.6799560306600001,
        0.7632964076600001,
        0.74207883866,
        0.6916640476600001,
        0.6824407086600001,
        0.68632427866
      ]
    }
  ]
}

Scenario: Isolated linker: fresh install, cold cache + cold store

Command Mean [s] Min [s] Max [s] Relative
pacquet@HEAD 4.104 ± 0.029 4.052 4.139 1.90 ± 0.11
pacquet@main 4.126 ± 0.037 4.085 4.180 1.91 ± 0.11
pnpr@HEAD 2.158 ± 0.125 2.021 2.385 1.00
pnpr@main 2.213 ± 0.137 2.074 2.424 1.03 ± 0.09
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 4.1037329093,
      "stddev": 0.028738226089050953,
      "median": 4.1048069316,
      "user": 3.61206562,
      "system": 3.32113474,
      "min": 4.0524101531,
      "max": 4.1391101501,
      "times": [
        4.1391101501,
        4.1057074941,
        4.1271148631,
        4.1039063691,
        4.1382964631,
        4.0836006981,
        4.0772285321,
        4.0524101531,
        4.0863864911,
        4.1235678791
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 4.1264874451999995,
      "stddev": 0.03727071642052666,
      "median": 4.1222457756,
      "user": 3.6413353199999996,
      "system": 3.3099778399999997,
      "min": 4.0850420481,
      "max": 4.1799494981,
      "times": [
        4.1799494981,
        4.0850420481,
        4.1190751291,
        4.1254164221,
        4.0874938861,
        4.1027992291,
        4.1290324441,
        4.1791005271,
        4.1672495721,
        4.0897156961
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 2.1578740324000005,
      "stddev": 0.1251495708233763,
      "median": 2.1351161580999998,
      "user": 2.45039572,
      "system": 2.8896050399999997,
      "min": 2.0207927451,
      "max": 2.3847101871,
      "times": [
        2.3543469341,
        2.0289145581,
        2.1215409561,
        2.1550256321,
        2.0207927451,
        2.1939972551,
        2.1347391471,
        2.1354931691,
        2.3847101871,
        2.0491797401
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 2.213468534,
      "stddev": 0.1372052814970433,
      "median": 2.1758914286,
      "user": 2.4827419199999996,
      "system": 2.85948804,
      "min": 2.0742688811,
      "max": 2.4237144921000002,
      "times": [
        2.0775522281,
        2.3888005311000002,
        2.0939139871,
        2.1554919761,
        2.0742688811,
        2.2030730661,
        2.1302200091,
        2.4237144921000002,
        2.3913592881,
        2.1962908811
      ]
    }
  ]
}

Scenario: Isolated linker: fresh install, hot cache + hot store

Command Mean [s] Min [s] Max [s] Relative
pacquet@HEAD 1.297 ± 0.018 1.273 1.325 2.02 ± 0.04
pacquet@main 1.293 ± 0.050 1.257 1.425 2.01 ± 0.08
pnpr@HEAD 0.644 ± 0.013 0.626 0.671 1.00 ± 0.02
pnpr@main 0.643 ± 0.008 0.626 0.649 1.00
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 1.2974532715600002,
      "stddev": 0.017624133914807587,
      "median": 1.29377813226,
      "user": 1.31993782,
      "system": 1.7025693,
      "min": 1.27262385126,
      "max": 1.3252899762600001,
      "times": [
        1.27262385126,
        1.3252899762600001,
        1.29376962626,
        1.2998637782600002,
        1.2836808942600002,
        1.29378663826,
        1.28455420226,
        1.3240995472600001,
        1.3107463212600001,
        1.28611788026
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 1.2931988168600002,
      "stddev": 0.0503322508887862,
      "median": 1.27625442076,
      "user": 1.2616462199999998,
      "system": 1.6978040999999997,
      "min": 1.25670708926,
      "max": 1.4245824122600002,
      "times": [
        1.2881720032600001,
        1.30638529226,
        1.4245824122600002,
        1.31581101826,
        1.28101885626,
        1.25670708926,
        1.2714899852600001,
        1.2626153392600001,
        1.25672563026,
        1.26848054226
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 0.64423451126,
      "stddev": 0.012961301154503403,
      "median": 0.64056695526,
      "user": 0.34005012,
      "system": 1.2620757,
      "min": 0.62555257426,
      "max": 0.67054417626,
      "times": [
        0.63437149026,
        0.62555257426,
        0.64154164526,
        0.67054417626,
        0.63959226526,
        0.65452224526,
        0.63757826126,
        0.65297755226,
        0.63527771426,
        0.65038718826
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 0.64252325906,
      "stddev": 0.0076658660261874605,
      "median": 0.64500077976,
      "user": 0.33227702000000003,
      "system": 1.2800753,
      "min": 0.62582990826,
      "max": 0.64894942726,
      "times": [
        0.62582990826,
        0.64695385026,
        0.64835901926,
        0.64297308326,
        0.63157141526,
        0.64722029526,
        0.64512781726,
        0.64487374226,
        0.64894942726,
        0.64337403226
      ]
    }
  ]
}

Scenario: Isolated linker: fresh install, cold cache + hot store

Command Mean [s] Min [s] Max [s] Relative
pacquet@HEAD 2.975 ± 0.046 2.909 3.051 4.60 ± 0.11
pacquet@main 2.908 ± 0.033 2.878 2.993 4.50 ± 0.09
pnpr@HEAD 0.647 ± 0.011 0.633 0.669 1.00
pnpr@main 0.647 ± 0.040 0.629 0.760 1.00 ± 0.06
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 2.97486675742,
      "stddev": 0.04607518935014748,
      "median": 2.96693781312,
      "user": 1.77156988,
      "system": 1.94652292,
      "min": 2.90896467412,
      "max": 3.0512176961199997,
      "times": [
        3.0085536661199996,
        2.92798424312,
        3.02235342512,
        2.9775190071199997,
        2.95335800812,
        2.95635661912,
        2.90896467412,
        3.0512176961199997,
        3.0065815801199998,
        2.93577865512
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 2.9077934362199995,
      "stddev": 0.03255845087278677,
      "median": 2.90137729262,
      "user": 1.6910249800000003,
      "system": 1.9341402199999997,
      "min": 2.87763407312,
      "max": 2.99284797112,
      "times": [
        2.91553106312,
        2.88973399012,
        2.87763407312,
        2.91857427312,
        2.88518798912,
        2.89840168912,
        2.90435408312,
        2.89131633412,
        2.99284797112,
        2.90435289612
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 0.64687430722,
      "stddev": 0.010947610420697435,
      "median": 0.6448605326200001,
      "user": 0.34366868,
      "system": 1.2850783199999998,
      "min": 0.6329135401200001,
      "max": 0.6687815051200001,
      "times": [
        0.6409157441200001,
        0.6436499961200001,
        0.6687815051200001,
        0.64507052612,
        0.65879928212,
        0.6329135401200001,
        0.64465053912,
        0.6532052391200001,
        0.6339346261200001,
        0.64682207412
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 0.64737526542,
      "stddev": 0.03980069492573665,
      "median": 0.6349486631200001,
      "user": 0.32640048000000005,
      "system": 1.26903312,
      "min": 0.6294656711200001,
      "max": 0.7599163681200001,
      "times": [
        0.64222336412,
        0.6335293901200001,
        0.63061965312,
        0.63699884512,
        0.6294656711200001,
        0.6306199831200001,
        0.6363679361200001,
        0.63215918512,
        0.7599163681200001,
        0.64185225812
      ]
    }
  ]
}

@github-actions

github-actions Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

🐰 Bencher Report

Branchpr/12497
Testbedpacquet
Click to view all benchmark results
BenchmarkLatencyBenchmark Result
milliseconds (ms)
(Result Δ%)
Upper Boundary
milliseconds (ms)
(Limit %)
isolated-linker.fresh-install.cold-cache.cold-store📈 view plot
🚷 view threshold
4,103.73 ms
(-2.48%)Baseline: 4,207.89 ms
5,049.47 ms
(81.27%)
isolated-linker.fresh-install.cold-cache.hot-store📈 view plot
🚷 view threshold
2,974.87 ms
(-0.71%)Baseline: 2,996.01 ms
3,595.21 ms
(82.75%)
isolated-linker.fresh-install.hot-cache.hot-store📈 view plot
🚷 view threshold
1,297.45 ms
(-1.85%)Baseline: 1,321.92 ms
1,586.30 ms
(81.79%)
isolated-linker.fresh-restore.cold-cache.cold-store📈 view plot
🚷 view threshold
3,906.01 ms
(-5.65%)Baseline: 4,139.95 ms
4,967.94 ms
(78.62%)
isolated-linker.fresh-restore.hot-cache.hot-store📈 view plot
🚷 view threshold
625.16 ms
(+1.99%)Baseline: 612.97 ms
735.57 ms
(84.99%)
🐰 View full continuous benchmarking report in Bencher

@github-actions

github-actions Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

🐰 Bencher Report

Branchpr/12497
Testbedpnpr

⚠️ WARNING: No Threshold found!

Without a Threshold, no Alerts will ever be generated.

Click here to create a new Threshold
For more information, see the Threshold documentation.
To only post results if a Threshold exists, set the --ci-only-thresholds flag.

Click to view all benchmark results
BenchmarkLatencymilliseconds (ms)
isolated-linker.fresh-install.cold-cache.cold-store📈 view plot
⚠️ NO THRESHOLD
2,157.87 ms
isolated-linker.fresh-install.cold-cache.hot-store📈 view plot
⚠️ NO THRESHOLD
646.87 ms
isolated-linker.fresh-install.hot-cache.hot-store📈 view plot
⚠️ NO THRESHOLD
644.23 ms
isolated-linker.fresh-restore.cold-cache.cold-store📈 view plot
⚠️ NO THRESHOLD
2,166.93 ms
isolated-linker.fresh-restore.hot-cache.hot-store📈 view plot
⚠️ NO THRESHOLD
677.68 ms
🐰 View full continuous benchmarking report in Bencher

Copilot AI review requested due to automatic review settings June 18, 2026 16:28
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit a5bc188

…in pacquet why

- Share a memo cache keyed by (ReverseKey, depth) across all root walks
  to avoid recomputing identical subtrees for overlapping matches
- Separate matching scan from tree building so all roots are collected
  before any expensive walks begin
- Support pruned lockfiles (missing packages:) by building node keys
  from snapshots instead of packages when the latter is absent
- Make WalkCtx.packages optional so display_version falls back to
  key.suffix.version() when metadata is unavailable

Refs: #12497
Comment thread pacquet/crates/cli/src/cli_args/why.rs
Comment thread pacquet/crates/cli/src/cli_args/why.rs
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 677e77d

APFS coarse mtime granularity can cause the second install to see a
stale manifest. Add a 100ms sleep after the manifest write to ensure
the filesystem is visible to the subprocess.

Refs: #12497
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit b0083e1

@zkochan zkochan merged commit ee9fab5 into pnpm:main Jun 19, 2026
27 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants