Skip to content

Support semver version ranges for dependencies - #311

Open
heatonmatthew wants to merge 24 commits into
helsing-ai:mainfrom
heatonmatthew:versioning
Open

Support semver version ranges for dependencies#311
heatonmatthew wants to merge 24 commits into
helsing-ai:mainfrom
heatonmatthew:versioning

Conversation

@heatonmatthew

@heatonmatthew heatonmatthew commented Apr 18, 2026

Copy link
Copy Markdown

Summary

This addresses and implements #205

  • Removes the exact-pin-only restriction — dependencies can now be specified with any semver range (^1.0, ~1.2.0, >=1.5.0, =1.0.0, etc.), not just =x.y.z
  • Adds list_versions() and resolve_version() to Artifactory — queries the registry for all available versions and selects the highest one satisfying the requirement; get_latest_version() is reimplemented on top of this
  • download() now takes an explicit &Version — resolution is decoupled from downloading; both Artifactory and LocalRegistry receive the resolved version directly
  • Adds find_satisfying() helpers to lockfile typesPackageLockfile, WorkspaceLockfile, and the Lockfile enum can now look up the best locked version matching a VersionReq, enabling the installer to skip the registry for already-locked ranges
  • Removes dependency_version_string() from src/registry/mod.rs along with the VersionNotPinned error — the old pin-enforcement code is gone entirely
  • Documentation — new reference pages for semver range syntax (docs/src/reference/semver.md) and the resolver (docs/src/reference/resolver.md); updated specifying-dependencies.md and buffrs-add.md
  • Tests — five new workspace-level integration test scenarios: caret/tilde resolution, compatible diamond, incompatible diamond, and multi-level tree; mock registry extended with query-string support to back these tests

What a reviewer should know

The resolver remains single-pass (interleaves graph walking and downloading). Version resolution now works as:

  1. Check the lockfile for a version satisfying the requirement → use it if found
  2. Check the local cache for a matching version → use it if found
  3. Call Artifactory::resolve_version() to query the registry → download the resolved exact version and cache it

Conflict detection (incompatible diamond) is handled in validate_version_compatibility() in src/resolver.rs — unchanged in structure, now exercised by the new test cases.

The query axum feature was enabled in Cargo.toml for the test mock registry to parse search query parameters.

Test plan

  • cargo build passes (requires protoc)
  • cargo test --workspace passes (requires protoc + Git LFS)
  • cargo clippy --all-targets --workspace -- -D warnings -D clippy::all passes
  • cargo fmt --check --all passes
  • New integration tests: cargo test --test e2e install covers the five new range scenarios

@heatonmatthew
heatonmatthew marked this pull request as draft April 18, 2026 01:27
@heatonmatthew
heatonmatthew marked this pull request as ready for review April 18, 2026 05:46
@heatonmatthew heatonmatthew changed the title feat: support semver version ranges for dependencies Support semver version ranges for dependencies Apr 18, 2026
Comment on lines +67 to +69
Local dependencies do not have a version requirement — the package at that
path is used as-is. They cannot be mixed with a remote entry for the same
package name.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we keep the removed lines:

Suggested change
Local dependencies do not have a version requirement — the package at that
path is used as-is. They cannot be mixed with a remote entry for the same
package name.
The `path` field is a relative path from the manifest file to the dependency's
root directory (the directory containing the dependency's `Proto.toml`).
Local dependencies do not have a version requirement — the package at that
path is used as-is. They cannot be mixed with a remote entry for the same
package name.
See [Local Dependencies](../guide/local-dependencies.md) for more information.

@heatonmatthew heatonmatthew Apr 28, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. Fixed.

Please check you're happy.

Comment thread src/resolver.rs Outdated
Comment thread src/lock.rs Outdated
Comment thread src/resolver.rs Outdated
};

// Phase 1: resolve the requirement to a concrete version.
// Try the lockfile first (avoids a registry round-trip when the lock is fresh).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The existing tests/cmd/install/workspace/lockfile/stale/mod.rs test already executes two consecutive installs and verifies the lockfile is reused and extended. However, afaik, it uses exact pins (=0.1.0), so it tests the old Lockfile::get() path with exact version matching, not explicitly covering the new find_satisfying() path with range matching.

Could you add a test for that path please?
For example: run install with ^1.0.0, get 1.2.0 locked, run install again, verify it reuses 1.2.0 from the lockfile without hitting the registry

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've now added tests/cmd/install/workspace/lockfile/range_pin_reuse/mod.rs to test this path.

Comment thread tests/cmd/install/workspace/range_caret_resolution/mod.rs
Comment thread src/resolver.rs
self.validate_compatibility(dependency, existing)
.wrap_err_with(|| format!("conflicting dependency on {}", package_name))?;
return Ok(());
}

@LinaUr LinaUr Apr 22, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think there might be a bug here.
IIUC, when a package is encountered for the second time (via a different path in the dependency tree), the resolver doesn't re-resolve.

For example, given:

  • pkg-1 requires lib-a ^1.0.0
  • pkg-2 requires lib-a <=1.2.0
  • highest version of lib-a is 1.5.0

If pkg-1 resolves first, the version is 1.5.0.
When pkg-2 tries to resolve, 1.5.0 doesn't satisfy <=1.2.0, so install fails.
However, in this case I'd expect it to resolve to 1.2.0 across the requirements.

If pkg-2 would resolves first, the version would satisfy pkg-1's requirement.

CMIIW, but since DependencyMap is HashMap<PackageName, DependencyManifest>, I believe the dependency iteration order is also non-deterministic. That would make an integration test for the order-dependency inherently flaky.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's worth adding a test for these cases.

@heatonmatthew heatonmatthew Apr 28, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for pointing this out. It's likely that I'll need to change the resolver algorithm away from the current single-pass recursive descent. It will need to keep track of aggregated package/requirement information, then fetch recursive transitives and flag any changes implied on already resolved package/requirements. Then this process would be looped until either 1. all requirements are met or 2. a subset of the requirements are irresolvable.

I'm working on it and will get back to you.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My apologies for the delay, I've had an extended illness. I'll try to get back to finishing this soon.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, I've made a bunch more changes and added new tests that should resolve this issue. Ready for another review, thanks.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The range_cascade_resolution and range_downgrade_diamond tests should exercise this now.

@mara-schulke mara-schulke added component::cli Everything related to the buffrs cli complexity::high Issues or ideas that are highly complex. require discussion and may affect backwards compatibility type::epic An epic change. This is going to make a big difference to buffrs as a product. labels Apr 22, 2026
Stop relying on HashMap iteration order when materializing the dep Vec.
Required for the resolver merge-and-resolve work to produce a stable
graph across runs.
…leStale

Reshape the error enum to express the cases the upcoming merge-and-resolve
algorithm produces. VersionConflict is deleted; validate_version_compatibility
is stubbed with Ok(()) until Task 3 removes it entirely.

Also renames Offline.version to Offline.requirements: Vec<VersionReq> and
updates both construction sites.

Temporarily ignores range_compatible_diamond and range_incompatible_diamond
— the new resolver behaviour is not yet in place.
The workspace lockfile is a pool keyed by (name, version) that is shared
across independently-resolved members, so a pin that fails the merged
requirements of one member is indistinguishable from another member's
legitimate entry. Treating that as a hard error is a false positive; the
resolver re-resolves against the registry instead.
…erging

GraphBuilder now collects every requirement edge in a package's dependency
graph into a per-package set. Resolution picks the highest version
satisfying every accumulated requirement at once, instead of locking in
the first encountered requirement's match, and retracts the edges a
superseded version contributed.

Merging is a within-graph concern: workspace members are resolved
independently by design, so the diamond that motivates this change is
expressed transitively.

  pkg1 -> lib-x -> lib-a ^1.0.0
  pkg1 -> lib-y -> lib-a <=1.2.0   -> resolves to 1.2.0

DependencyNode::version now carries the exact resolved version so the
installer cannot re-resolve upward past the resolver's pick. Cycle
detection moves from a mid-descent visiting set (which cannot fire under
BFS) to a topological check on the finished graph.
Covers the case where a transitive edge tightens an already-resolved
package's requirement set so the previous version no longer fits: the
edges that version contributed are retracted, newly-unreachable packages
are dropped, and the new version's transitives are walked.

Also re-enables the incompatible-diamond test against the
NoCompatibleVersion variant, which now reports the full requirement set
rather than the first pairwise conflict.
Adds a fixture where a second transitive path tightens a package's
requirements past the existing pin, and asserts the resolver re-resolves
and rewrites the lockfile rather than failing. Also pins down that the
pooled workspace lockfile is unaffected: entries belonging to other
members are skipped, not mistaken for stale pins.
range_cascade_resolution exercises retraction end-to-end, but only one
level of it: the dropped package contributes no further edges. Add unit
tests over retract_contributions_from for the branches it cannot reach —
transitive cascade through a dropped package, rescheduling a survivor
whose pick no longer fits the shrunken requirement set, and leaving a
still-satisfied survivor at its current version.
range_pin_reuse validates that once a package version enters the
lockfile, if it still satisfies all range criteria, that version is
reused without hitting the registry.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

complexity::high Issues or ideas that are highly complex. require discussion and may affect backwards compatibility component::cli Everything related to the buffrs cli type::epic An epic change. This is going to make a big difference to buffrs as a product.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants