en

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:

  1. Starts a worklist with all direct <PackageReference /> entries.
  2. 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.)
  3. For each candidate version, walks its .nuspec dependencies and adds them to the worklist with the version range they require.
  4. When the same package is required by multiple paths, accumulates the range. The version picked is the highest that satisfies all accumulated ranges.
  5. If no version satisfies the accumulated range, the resolution fails with a ResolutionConflict and pcpm install exits 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 exclusive
  • 1.0.* — floating, resolved to the highest matching version
  • 1.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 in Directory.Packages.props that 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.