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.
| Resolver | Default? | Algorithm | Notes |
|---|---|---|---|
| Greedy-fusion | Yes | First-match version pick, reuse-on-compatible / allocate-on-incompatible | Faster. Bun-style. The fused dispatcher IS the metadata fetch dispatcher — no separate walker spawn. |
| PubGrub-with-split-retry | No (opt-in) | Conflict-driven backtracking | Slower but exhaustive. |
Pick the alternative for one invocation:
LPM_RESOLVER=pubgrub lpm installFor deeper debugging of the dispatcher, force the walker-arm orchestration:
LPM_GREEDY_FUSION=0 lpm installBoth 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:
- 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.
- 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@^3 — 4.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:
- Pop a pending edge off the task queue.
- 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
Notifyfor an in-flight fetch to complete. - Pick a version per the rules above.
- 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
peerDependencynot already in the resolved tree. These "ambient" installs surface atnode_modules/<name>/even though they aren't inpackage.json.check_unmet_peersthen 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(defaulttrue) →~/.lpm/config.toml > auto-install-peers→ built-in default. Set tofalsefor 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:
| Source | Priority |
|---|---|
package.json > lpm > overrides | Highest |
package.json > overrides (npm-style) | Middle |
package.json > resolutions (yarn-style) | Lowest |
Selectors:
"foo"— every instance offoo"foo@<1.0.0"— instances whose natural version satisfies a range"baz>foo"/"baz>foo@1"— path-selector: instances reached throughbaz
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 var | Effect |
|---|---|
LPM_RESOLVER=pubgrub | Use PubGrub-with-split-retry instead of greedy-fusion |
LPM_GREEDY_FUSION=0 | Force walker-arm orchestration (debug) |
LPM_NPM_FANOUT=N | Concurrent npm metadata fetches (default 256) |
LPM_WALKER=stream | Use 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 anlpm.overridesentry. - Resolution is slow → check whether your tree exercises the multi-version path repeatedly, or try
LPM_NPM_FANOUThigher. - A new release breaks a peer dep → check
lpm installoutput for unmet peer warnings; add the peer todependenciesto opt into the resolver's allocation.
See also
lpm install— runs the resolver as part of the install pipelinelpm resolve— runs the resolver standalone, prints the tree, no install- Lockfile — what gets persisted after resolution
package.jsonoverrides — selector grammar- Environment variables — every tunable