Dependency resolution
Union-of-constraints BFS, conflict detection, and how PCPM picks the right version.
PCPM’s resolver is deliberately small: pure logic, no I/O, no heuristics. It walks the dependency graph with BFS, accumulates constraints per package, and picks the highest version that satisfies the union of all of them.
The algorithm
Given a workspace with N projects and a set of direct dependencies, the resolver:
- Starts a worklist with all direct
<PackageReference />entries. - Pops a package off the worklist. If it’s not yet in the resolved
set, queries the feed for its versions. (The feed layer is
pluggable, but the default is
NuGetFeed, which talks raw HTTP to nuget.org.) - For each candidate version, walks its
.nuspecdependencies and adds them to the worklist with the version range they require. - When the same package is required by multiple paths, accumulates the range. The version picked is the highest that satisfies all accumulated ranges.
- If no version satisfies the accumulated range, the resolution
fails with a
ResolutionConflictandpcpm installexits 1.
The whole thing is O(V + E) in the size of the resolved graph,
with caching that makes re-runs free. It’s also pure: given the
same workspace and the same feeds, you get the same lockfile, on
every machine, every commit.
Union of constraints
The interesting part is step 4. Suppose apps/web wants
Microsoft.Extensions.Logging >= 8.0.0, and libs/contracts wants
Microsoft.Extensions.Logging >= 7.0.1. The union is
>= 7.0.1 — both constraints are satisfied by picking the highest
version available. If both projects are happy with [8.0.0, 9.0.0),
PCPM picks the highest 8.x.
If apps/web says >= 8.0.0 and libs/contracts says < 8.0.0,
the union is empty. That’s a conflict, and PCPM reports it
explicitly with the two source projects and the version ranges that
disagree.
Range parsing
PCPM delegates version-range parsing to NuGet.Versioning, the
same library that dotnet restore itself uses. That means:
[1.0.0, 2.0.0)— minimum inclusive, maximum exclusive1.0.*— floating, resolved to the highest matching version1.0.0-beta.*— floating on a pre-release tag*— any version
This is the same syntax you write in your .nuspec files; PCPM
doesn’t introduce a new one. The behavior is the same as
dotnet restore, including pre-release semantics, so you don’t
get surprised by an upgrade.
Floating versions
By default, PCPM pins floating ranges. A <PackageReference Include="X" Version="1.0.*" /> in CPM is resolved to the highest
matching stable version at the time of pcpm install, and that
version is written to pcpm.lock as a fixed string. Subsequent
runs do not re-resolve.
To opt into “always pick the latest” behavior, set
"lockfile": { "floating": "follow" } in pcpm.json. With this
setting, pcpm install will re-query the feed for floating ranges
on every run. We do not recommend this for CI — pcpm ci ignores
the setting and always uses the lockfile as the source of truth.
Conflict reporting
When PCPM can’t satisfy the union, it prints a ResolutionConflict
to the console and exits with a non-zero status. The message has
the form:
× Resolution conflict: Microsoft.Extensions.Logging
Required by:
apps/web (>= 8.0.0)
libs/contracts (< 8.0.0)
No version satisfies both. Either pin one project to a specific
version, or refactor so the constraint disappears.
The fix is almost always one of:
- Add a
<PackageVersion />entry inDirectory.Packages.propsthat pins the package to a version both projects can use. - Update one of the projects to a newer major.
- Use
pcpm why <pkg>to see which packages are dragging the constraint in transitively.
pcpm why walks the lockfile and prints the chain of dependents
that brought a package into the resolved graph. It’s an essential
debugging tool when a transitive bump surprises you.
Pure logic, easy to test
The resolver is in pcpm.Core.Services.DependencyResolver. It has
no I/O dependencies: it takes a feed and a workspace, returns a
resolved graph. The unit tests cover:
- Happy-path BFS.
- Transitive resolution with a multi-hop chain.
- Conflict detection with two incompatible ranges.
- Floating version resolution.
This is the design payoff: because the resolver is pure, the
abstractions it defines (the feed, the workspace) are the contract.
Want a different feed? Implement INuGetFeed. Want to test
against a fake feed? Substitute a stub. The test suite does
exactly this, and it’s the reason the resolver is so confident.