LPM CLI

Resolver

How LPM CLI picks versions — greedy-fusion by default, with PubGrub as the documented fallback.

The resolver decides which version of every transitive dependency lands in your node_modules/. It walks package.json, talks to the appropriate registry for each canonical package, and computes a (name, version) set that satisfies every declared semver range. This page is the conceptual deep-dive.

Two resolvers

LPM CLI ships two resolvers in the same binary. Both speak the same npm-compatible semver dialect (^, ~, ||, *, x, hyphen ranges, prereleases, latest/beta/next dist-tags) and produce the same lockfile shape. They differ in how they pick versions when multiple ranges target the same canonical package.

ResolverDefault?AlgorithmNotes
Greedy-fusionYesFirst-match version pick, reuse-on-compatible / allocate-on-incompatibleFaster. Bun-style. The fused dispatcher IS the metadata fetch dispatcher — no separate walker spawn.
PubGrub-with-split-retryNo (opt-in)Conflict-driven backtrackingSlower but exhaustive.

Pick the alternative for one invocation:

LPM_RESOLVER=pubgrub lpm install

For deeper debugging of the dispatcher, force the walker-arm orchestration:

LPM_GREEDY_FUSION=0 lpm install

Both are stable. The default is greedy-fusion because it's faster and matches bun/npm/pnpm semantics for the multi-version case below.

How greedy-fusion picks versions

The resolver maintains a graph keyed by (canonical, version). When it processes an edge:

  1. Reuse-on-compatible. If the canonical already has a node and that node's version satisfies the new edge's range, the new edge points at the existing node. First-version-wins inside any single satisfying range bucket — same as bun, npm, and pnpm.
  2. Allocate-on-incompatible. If no existing node satisfies the new range, the resolver picks the best available version for the new range and allocates a new (canonical, version) node. Both versions live independently in the resolved tree.

Example: edge A wants lodash@^4. The resolver picks lodash@4.17.21 and allocates one node. Edge B then wants lodash@^4 — same range bucket, reuses A's node. Edge C wants lodash@^34.17.21 doesn't satisfy ^3, so the resolver allocates a new lodash@3.10.1 node. The tree now has two lodash versions, each shared by everyone whose range pointed at it.

This is the "multi-version per canonical" property. npm has it. pnpm has it. Yarn-classic (with hoisting) approximated it. LPM CLI follows the bun-shaped recipe.

Candidate policy

The resolver walks available versions newest-first. Semver range satisfaction is the first filter, then LPM CLI applies install policy:

  • Minimum release age skips too-new matching candidates for direct/root package ranges by default and keeps walking to the newest mature candidate. Strict release-age policy extends the same check to transitive candidates and lockfile replays. Exact checked pins to a too-new version fail.
  • trust-policy = "no-downgrade" skips candidates that reduce npm trusted-publisher or staged-publish evidence compared with an earlier published version. Registry attestation pointers do not count before verification. Ranges can fall back to an older allowed candidate; exact blocked pins fail with the trust-policy reason. After selection, the install gate separately requires new verified provenance when the lockfile contains verified history for that package name.
  • Platform metadata (os, cpu, libc) is preserved in the lockfile and applied at reify/filter time, not used to hide the newest semver-satisfying version from the lockfile. Incompatible optional packages are skipped on the current host; incompatible required packages fail with a platform error.

This split keeps lockfiles reviewable and portable across hosts while still avoiding known-bad candidates before the graph is committed.

Resolution failures

When no compatible package version exists, LPM CLI fails before writing the lockfile or touching node_modules/. The error names the dependency edge that failed, the package that required it when that context is available, and the newest published version LPM CLI saw.

✗ Could not resolve dependencies
  package missing-leaf@^2.0.0
  required by parent-pkg@1.0.0
  reason no published version satisfies ^2.0.0
  available 1 version, newest 1.0.0

--json uses the stable top-level error_code: "resolution_failed" and puts resolver-specific fields in the error object:

{
  "success": false,
  "error_code": "resolution_failed",
  "error": {
    "code": "RESOLUTION_FAILED",
    "message": "failed to resolve missing-leaf@^2.0.0 required by parent-pkg@1.0.0: no published version satisfies ^2.0.0",
    "package": "missing-leaf",
    "requested": "^2.0.0",
    "dependency": "missing-leaf",
    "kind": "no_matching_version",
    "reason": "no published version satisfies ^2.0.0",
    "required_by": "parent-pkg@1.0.0",
    "available_versions": 1,
    "newest_version": "1.0.0"
  }
}

kind distinguishes the class of resolver failure (no_matching_version, platform_incompatible, policy_blocked, fetch_failed, no_solution, or peer_conflict). Use that field for automation that needs to decide whether to relax a range, change a platform target, approve a policy exception, or retry a registry lookup.

Streaming dispatch

The resolver loop is single-threaded — bun's PackageManager event loop runs on one thread, and parallelism comes from I/O fan-out. Each iteration:

  1. Pop a pending edge off the task queue.
  2. Resolve the canonical's manifest. Fast path: the shared cache hit — the BFS walker has been prefetching concurrently. Slow path: wait on the per-canonical Notify for an in-flight fetch to complete.
  3. Pick a version per the rules above.
  4. Enqueue the chosen version's deps for the next iteration.

The dispatcher and metadata fetcher are fused in the default resolver — the same loop drives both, so the resolver sees in-flight metadata land as it streams in rather than waiting for level-step batch fetches.

peerDependencies and optionalDependencies

  • Peer deps are auto-installed by default. After the main resolution pass, an eager peer-drain step synthesizes installs for every non-optional peerDependency not already in the resolved tree. These "ambient" installs surface at node_modules/<name>/ even though they aren't in package.json. check_unmet_peers then runs a post-pass to warn on anything that couldn't be satisfied (e.g., peer-range conflicts between consumers).
  • Toggle the auto-install with package.json > lpm > autoInstallPeers (default true) → ~/.lpm/config.toml > auto-install-peers → built-in default. Set to false for npm-classic / pnpm-classic semantics (no synthesis, peer warnings only).
  • Optional peers (peerDependenciesMeta.<name>.optional = true) are never auto-installed regardless of the flag — the manifest author opted out of the dependency entirely.
  • Optional deps are tried; failure to resolve or download is logged but doesn't fail the install.

Overrides and resolutions

The resolver consumes three sources of override declarations, with lpm.overrides winning on conflict:

SourcePriority
package.json > lpm > overridesHighest
package.json > overrides (npm-style)Middle
package.json > resolutions (yarn-style)Lowest

Selectors:

  • "foo" — every instance of foo
  • "foo@<1.0.0" — instances whose natural version satisfies a range
  • "baz>foo" / "baz>foo@1" — path-selector: instances reached through baz

Multi-segment paths (a>b>c) are rejected at parse time. See package.json overrides.

npm aliases

Deps declared as "my-pkg": "npm:other-pkg@^1.0.0" (npm-alias edges) are passed through the resolver verbatim. The cache populates an aliases map; the resolved tree records each alias edge. The lockfile's alias-dependencies and root-aliases blocks preserve the metadata for warm installs.

Projects with alias edges, peer pinning, dependency engine constraints, platform metadata, optional-reachability state, catalogs, or registry signatures may write only lpm.lock — the binary lockfile (lpm.lockb) is skipped whenever its v3 wire format cannot represent the TOML metadata. See lpm.lockb format.

Tunables

Env varEffect
LPM_RESOLVER=pubgrubUse PubGrub-with-split-retry instead of greedy-fusion
LPM_GREEDY_FUSION=0Force walker-arm orchestration (debug)
LPM_NPM_FANOUT=NConcurrent npm metadata fetches (default 256)
LPM_WALKER=streamUse the continuous-stream walker; default is the level-step BFS

When to look at the resolver

  • An install picked a transitive version you didn't expect → use lpm graph --why <pkg> to trace the path, then add an lpm.overrides entry.
  • Resolution is slow → check whether your tree exercises the multi-version path repeatedly, or try LPM_NPM_FANOUT higher.
  • A new release breaks a peer dep → check lpm install output for unmet peer warnings; add the peer to dependencies to opt into the resolver's allocation.

See also