Skip to content

perf(pacquet): port pnpm's lockfile-pruner BFS to re-derive transitive optional #11916

Description

@zkochan

Context

Pacquet's resolver inherits a longstanding pnpm-CLI quirk: on revisit of a package, ResolvedPackage.optional is AND-folded only on the directly-visited package — not on its transitive descendants. Walked first via an optional path and then revisited via a non-optional path, the direct package gets optional: false correctly, but its already-walked children stay stuck at optional: true.

Concrete scenario:

// root manifest
{
  "dependencies":         { "B": "^1" },
  "optionalDependencies": { "A": "^1" }
}
// A.dependencies: { "C": "^1" }
// B.dependencies: { "A": "^1" }

Walk order: optional → A → C (sets C.optional = true), then prod → B → A (revisits A, sets A.optional = false). C should now be optional: false (reachable through the all-non-optional path prod → B → A → C) but the in-memory ResolvedPackage.optional stays true.

Upstream observed at:

How pnpm CLI hides this from users

lockfile/pruner/src/index.ts:160-205 (copyDependencySubGraph) does a BFS from importers through the dep graph, tracking a nonOptional set. Any package reached by an all-non-optional path is stamped optional: false on both depLockfile (the snapshot) and dependenciesGraph (the in-memory graph the installer reads). Install-time consumers — e.g. installing/deps-installer/src/install/link.ts:92 and link.ts:519 — read from the post-pruner snapshot or graph, so they never see the stale resolver value.

Why pacquet is exposed

Pacquet has no equivalent pruner pass. dependencies_graph_to_lockfile.rs:409 writes the raw node.optional (sourced from the resolver) straight to the lockfile snapshot, and the install-time gates (create_virtual_store.rs:762 for fetch-side failures, installability.rs:394 for build-side) read from that snapshot. So a fetch or build failure on a transitively-required package can be silently tolerated as if it were optional.

Proposal

Port copyDependencySubGraph's BFS into pacquet. Run it after resolve_importer returns (or as part of dependencies_graph_to_lockfile) so the lockfile and any in-memory consumers see the corrected optional values.

Add a regression test that builds the scenario above and asserts that C.optional == false after resolution.

Why not fix the resolver directly

Fixing the resolver to do the optional fold during the walk (re-walking the cached subtree on every revisit) defeats the lazy-children optimization that #11915 just introduced. The pruner BFS is O(V+E) and runs once at the end, so it's strictly cheaper than per-revisit re-walks. Upstream made the same design choice — keeping the resolver fast and centralising the correction at the lockfile/graph boundary.

Follow-up to

#11915 — surfaced during CodeRabbit review of the lazy-children PR.


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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions