Skip to content

Commit 43fd240

Browse files
authored
[PubGrub] Narrow down cases when pre-release decisions are marked invalid (#7799)
Pre-release decisions are valid if the derived range they were inferred from supports pre-release versions. ### Motivation: Currently, after deriving a pre-release assignment, the solver would assert in some circumstances depending on order in which the dependencies are specified in the manifest. This change makes it possible to infer pre-release verison of a package even when intermediate derivations did not support pre-release versions. For example one package could declare `swift-syntax` dependency as `508.0.1` ..< `601.0.0` and another could narrow it to `600.0.0-latest` ..< `601.0.0`. Since the most constrained range in such case supports pre-releases a decision to use the lastest pre-release version of `600.0.0` should be valid if there is no `600.0.0` release yet. It's important to note that on each path a decision for a package is always made based on the last undecided term's requirements which means - based on the current semantics - if such derivation doesn't support pre-release, even though intermediate ones did, the solver would select a released version. This PR is not aim to change that. There is a caveat which we cannot narrowly fix - if package depends on an exact pre-release version the inference is going to fail when there is another dependency to the same package that doesn't support pre-releases. For example: Package `A` depends on packages `B` and `C` as follows: `A` depends on `B` = `0.0.1-alpha` and `C` = `1.0.0` `B` version `0.0.1-alpha` has no dependencies `C` version `1.0.0` depends on `B` - `0.0.1` ..< `0.0.2` In this situation the solver would fail because `0.0.1-alpha` does not satisfy range - `0.0.1` ..< `0.0.2` inferred from `C`. ### Modifications: - Delays version decisions for packages with pre-release ranges until there are no more packages without pre-release versions. This also means that if a previous decision narrows down the range to releases-only the package would be made a contender on the next step of the solver. - Relaxes the `isValidDecision` check for pre-release decisions but makes sure that the last derivation supports pre-release versions. ### Result: In situations like #7658 and #7643 the solver would no longer assert and would produce a solution with a pre-release version for `swift-syntax`.
1 parent 1990046 commit 43fd240

File tree

5 files changed

+285
-2
lines changed

5 files changed

+285
-2
lines changed

Sources/PackageGraph/Resolution/PubGrub/PubGrubDependencyResolver.swift

+11-1
Original file line numberDiff line numberDiff line change
@@ -764,7 +764,17 @@ public struct PubGrubDependencyResolver {
764764
let start = DispatchTime.now()
765765
let counts = try result.get()
766766
// forced unwraps safe since we are testing for count and errors above
767-
let pkgTerm = undecided.min { counts[$0]! < counts[$1]! }!
767+
let pkgTerm = undecided.min {
768+
// Prefer packages that don't allow pre-release versions
769+
// to allow propagation logic to find dependencies that
770+
// limit the range before making any decisions. This means
771+
// that we'd always prefer release versions.
772+
if $0.supportsPrereleases != $1.supportsPrereleases {
773+
return !$0.supportsPrereleases
774+
}
775+
776+
return counts[$0]! < counts[$1]!
777+
}!
768778
self.delegate?.willResolve(term: pkgTerm)
769779
// at this point the container is cached
770780
let container = try self.provider.getCachedContainer(for: pkgTerm.node.package)

Sources/PackageGraph/Resolution/PubGrub/Term.swift

+34-1
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ public struct Term: Equatable, Hashable {
4040
)
4141
}
4242

43+
package var supportsPrereleases: Bool {
44+
self.requirement.supportsPrereleases
45+
}
46+
4347
/// Check if this term satisfies another term, e.g. if `self` is true,
4448
/// `other` must also be true.
4549
public func satisfies(_ other: Term) -> Bool {
@@ -100,9 +104,38 @@ public struct Term: Equatable, Hashable {
100104
/// - There has to be no decision for it.
101105
/// - The package version has to match all assignments.
102106
public func isValidDecision(for solution: PartialSolution) -> Bool {
107+
// The intersection between release and pre-release ranges is
108+
// allowed to produce a pre-release range. This means that the
109+
// solver is allowed to make a pre-release version decision
110+
// even when some of the versions didn't allow pre-releases.
111+
//
112+
// This means that we should ignore pre-release differences
113+
// while checking derivations and assert only if the term is
114+
// pre-release but the last assignment wasn't.
115+
if self.supportsPrereleases {
116+
if let assignment = solution.assignments.last(where: { $0.term.node == self.node }) {
117+
assert(assignment.term.supportsPrereleases)
118+
}
119+
}
120+
103121
for assignment in solution.assignments where assignment.term.node == self.node {
104122
assert(!assignment.isDecision, "Expected assignment to be a derivation.")
105-
guard self.satisfies(assignment.term) else { return false }
123+
124+
// This is not great but dragging `ignorePrereleases` through all the APIs seems
125+
// worse. This is valid because we can have a derivation chain which is something
126+
// like - "0.0.1"..<"1.0.0" -> "0.0.4-latest"..<"0.0.6" and make a decision
127+
// `0.0.4-alpha5` based on that if there is no `0.0.4` release. In vacuum this is
128+
// (currently) incorrect because `0.0.4-alpha5` doesn't satisfy the initial
129+
// range that doesn't support pre-release versions. Since the solver is
130+
// allowed to derive a pre-release range we consider the original range to
131+
// be pre-release range implicitly.
132+
let term = if self.supportsPrereleases && !assignment.term.supportsPrereleases {
133+
Term(self.node, self.requirement.withoutPrereleases)
134+
} else {
135+
self
136+
}
137+
138+
guard term.satisfies(assignment.term) else { return false }
106139
}
107140
return true
108141
}

Sources/PackageGraph/VersionSetSpecifier.swift

+63
Original file line numberDiff line numberDiff line change
@@ -466,6 +466,38 @@ extension VersionSetSpecifier {
466466
}
467467
}
468468

469+
extension VersionSetSpecifier {
470+
package var supportsPrereleases: Bool {
471+
switch self {
472+
case .empty, .any:
473+
false
474+
case .exact(let version):
475+
version.supportsPrerelease
476+
case .range(let range):
477+
range.supportsPrereleases
478+
case .ranges(let ranges):
479+
ranges.contains(where: \.supportsPrereleases)
480+
}
481+
}
482+
483+
package var withoutPrereleases: VersionSetSpecifier {
484+
if !supportsPrereleases {
485+
return self
486+
}
487+
488+
return switch self {
489+
case .empty, .any:
490+
self
491+
case .range(let range):
492+
.range(range.withoutPrerelease)
493+
case .ranges(let ranges):
494+
.ranges(ranges.map { $0.withoutPrerelease })
495+
case .exact(let version):
496+
.exact(version.withoutPrerelease)
497+
}
498+
}
499+
}
500+
469501
extension VersionSetSpecifier: CustomStringConvertible {
470502
public var description: String {
471503
switch self {
@@ -505,4 +537,35 @@ fileprivate extension Range where Bound == Version {
505537
func isHigherThan(_ other: Range<Bound>) -> Bool {
506538
return other.isLowerThan(self)
507539
}
540+
541+
var supportsPrereleases: Bool {
542+
self.lowerBound.supportsPrerelease || self.upperBound.supportsPrerelease
543+
}
544+
545+
var withoutPrerelease: Range<Version> {
546+
if !supportsPrereleases {
547+
return self
548+
}
549+
550+
return Range(uncheckedBounds: (
551+
lower: self.lowerBound.withoutPrerelease,
552+
upper: self.upperBound.withoutPrerelease
553+
))
554+
}
555+
}
556+
557+
fileprivate extension Version {
558+
var supportsPrerelease: Bool {
559+
!self.prereleaseIdentifiers.isEmpty
560+
}
561+
562+
var withoutPrerelease: Version {
563+
Version(
564+
self.major,
565+
self.minor,
566+
self.patch,
567+
prereleaseIdentifiers: [],
568+
buildMetadataIdentifiers: self.buildMetadataIdentifiers
569+
)
570+
}
508571
}

Tests/PackageGraphTests/PubgrubTests.swift

+152
Original file line numberDiff line numberDiff line change
@@ -3282,6 +3282,158 @@ final class PubGrubBacktrackTests: XCTestCase {
32823282
.contains(where: { $0.message == "[DependencyResolver] resolved 'd' @ '2.3.0'" })
32833283
)
32843284
}
3285+
3286+
func testPrereleaseVersionSelection() throws {
3287+
try builder.serve("a", at: "1.0.0", with: [
3288+
"a": [
3289+
"b": (.versionSet(.range("0.0.8"..<"2.0.0")), .specific(["b"]))
3290+
]
3291+
])
3292+
3293+
try builder.serve("b", at: "1.0.0-prerelease-20240710")
3294+
3295+
try builder.serve("c", at: "1.0.0", with: [
3296+
"c": [
3297+
"d": (.versionSet(.range("1.0.5"..<"2.0.0")), .specific(["d"]))
3298+
]
3299+
])
3300+
try builder.serve("c", at: "1.0.1")
3301+
3302+
try builder.serve("d", at: "1.0.0-prerelease-20240710")
3303+
try builder.serve("d", at: "1.0.6-prerelease-1")
3304+
try builder.serve("d", at: "1.0.6")
3305+
3306+
let resolver = builder.create()
3307+
// The order matters here because solver used to assign `b` before `a`.
3308+
let dependencies1 = try builder.create(dependencies: [
3309+
"a": (.versionSet(.exact("1.0.0")), .specific(["a"])),
3310+
"b": (.versionSet(.range("1.0.0-latest"..<"2.0.0")), .specific(["b"]))
3311+
])
3312+
3313+
AssertResult(resolver.solve(constraints: dependencies1), [
3314+
("a", .version("1.0.0")),
3315+
("b", .version("1.0.0-prerelease-20240710"))
3316+
])
3317+
3318+
let dependencies2 = try builder.create(dependencies: [
3319+
"c": (.versionSet(.exact("1.0.0")), .specific(["c"])),
3320+
"d": (.versionSet(.range("1.0.0-latest"..<"2.0.0")), .specific(["d"]))
3321+
])
3322+
3323+
AssertResult(resolver.solve(constraints: dependencies2), [
3324+
("c", .version("1.0.0")),
3325+
("d", .version("1.0.6"))
3326+
])
3327+
}
3328+
3329+
func testPrereleaseExactRequirement() throws {
3330+
try builder.serve("c", at: "1.0.0", with: [
3331+
"c": [
3332+
"d": (.versionSet(.range("1.0.4"..<"2.0.0")), .specific(["d"]))
3333+
]
3334+
])
3335+
try builder.serve("c", at: "1.0.1")
3336+
3337+
try builder.serve("d", at: "1.0.0-prerelease-20240710")
3338+
try builder.serve("d", at: "1.0.4")
3339+
try builder.serve("d", at: "1.0.6-prerelease-1")
3340+
3341+
let resolver = builder.create()
3342+
3343+
let exactDependencies = try builder.create(dependencies: [
3344+
"c": (.versionSet(.exact("1.0.0")), .specific(["c"])),
3345+
"d": (.versionSet(.exact("1.0.6-prerelease-1")), .specific(["d"]))
3346+
])
3347+
3348+
// FIXME: This should produce a valid solution but cannot at the
3349+
// moment because "1.0.4"..<"2.0.0" doesn't support pre-release versions
3350+
// and there is no way to infer it.
3351+
let resultWithExact = resolver.solve(constraints: exactDependencies)
3352+
XCTAssertMatch(
3353+
resultWithExact.errorMsg,
3354+
.contains("Dependencies could not be resolved because root depends on 'd' 1.0.6-prerelease-1")
3355+
)
3356+
3357+
let rangeDependency = try builder.create(dependencies: [
3358+
"c": (.versionSet(.exact("1.0.0")), .specific(["c"])),
3359+
"d": (.versionSet(.range("1.0.5"..<"1.0.6-prerelease-2")), .specific(["d"]))
3360+
])
3361+
3362+
let resultWithRange = resolver.solve(constraints: rangeDependency)
3363+
AssertResult(resultWithRange, [
3364+
("c", .version("1.0.0")),
3365+
("d", .version("1.0.6-prerelease-1"))
3366+
])
3367+
}
3368+
3369+
func testReleaseOverPrerelease() throws {
3370+
try builder.serve("a", at: "1.0.0", with: [
3371+
"a": [
3372+
"b": (.versionSet(.range("0.0.8"..<"2.0.0")), .specific(["b"]))
3373+
]
3374+
])
3375+
3376+
try builder.serve("b", at: "1.0.0-prerelease-20240616")
3377+
try builder.serve("b", at: "1.0.0")
3378+
3379+
let resolver = builder.create()
3380+
// The order matters here because solver used to assign `b` before `a`.
3381+
let dependencies1 = try builder.create(dependencies: [
3382+
"a": (.versionSet(.exact("1.0.0")), .specific(["a"])),
3383+
"b": (.versionSet(.range("1.0.0-latest"..<"2.0.0")), .specific(["b"]))
3384+
])
3385+
3386+
AssertResult(resolver.solve(constraints: dependencies1), [
3387+
("a", .version("1.0.0")),
3388+
("b", .version("1.0.0"))
3389+
])
3390+
3391+
let dependencies2 = try builder.create(dependencies: [
3392+
"b": (.versionSet(.range("1.0.0-latest"..<"2.0.0")), .specific(["b"])),
3393+
"a": (.versionSet(.exact("1.0.0")), .specific(["a"]))
3394+
])
3395+
3396+
AssertResult(resolver.solve(constraints: dependencies2), [
3397+
("a", .version("1.0.0")),
3398+
("b", .version("1.0.0"))
3399+
])
3400+
}
3401+
3402+
func testPrereleaseInferenceThroughDependencies() throws {
3403+
try builder.serve("a", at: "1.0.0", with: [
3404+
"a": [
3405+
"b": (.versionSet(.range("1.0.0"..<"2.0.0-latest")), .specific(["b"]))
3406+
]
3407+
])
3408+
3409+
try builder.serve("b", at: "0.0.8-prerelease-20230310")
3410+
try builder.serve("b", at: "0.0.8")
3411+
try builder.serve("b", at: "1.0.0-prerelease-20230616")
3412+
try builder.serve("b", at: "1.0.0")
3413+
try builder.serve("b", at: "1.9.9-prerelease-20240702")
3414+
3415+
let resolver = builder.create()
3416+
// The order matters here because solver used to assign `b` before `a`.
3417+
let dependencies1 = try builder.create(dependencies: [
3418+
"a": (.versionSet(.exact("1.0.0")), .specific(["a"])),
3419+
"b": (.versionSet(.range("0.0.8"..<"2.0.0")), .specific(["b"]))
3420+
])
3421+
3422+
AssertResult(resolver.solve(constraints: dependencies1), [
3423+
("a", .version("1.0.0")),
3424+
("b", .version("1.9.9-prerelease-20240702"))
3425+
])
3426+
3427+
let dependencies2 = try builder.create(dependencies: [
3428+
"b": (.versionSet(.range("0.0.8"..<"2.0.0")), .specific(["b"])),
3429+
"a": (.versionSet(.exact("1.0.0")), .specific(["a"]))
3430+
])
3431+
3432+
AssertResult(resolver.solve(constraints: dependencies2), [
3433+
("a", .version("1.0.0")),
3434+
("b", .version("1.9.9-prerelease-20240702"))
3435+
])
3436+
}
32853437
}
32863438

32873439
extension PinsStore.PinState {

Tests/PackageGraphTests/VersionSetSpecifierTests.swift

+25
Original file line numberDiff line numberDiff line change
@@ -151,4 +151,29 @@ final class VersionSetSpecifierTests: XCTestCase {
151151
XCTAssertTrue(VersionSetSpecifier.range("2.0.1"..<"2.0.2") == VersionSetSpecifier.ranges(["2.0.1"..<"2.0.2"]))
152152
XCTAssertTrue(VersionSetSpecifier.ranges(["2.0.1"..<"2.0.2"]) == VersionSetSpecifier.range("2.0.1"..<"2.0.2"))
153153
}
154+
155+
func testPrereleases() {
156+
XCTAssertFalse(VersionSetSpecifier.any.supportsPrereleases)
157+
XCTAssertFalse(VersionSetSpecifier.empty.supportsPrereleases)
158+
XCTAssertFalse(VersionSetSpecifier.exact("0.0.1").supportsPrereleases)
159+
160+
XCTAssertTrue(VersionSetSpecifier.exact("0.0.1-latest").supportsPrereleases)
161+
XCTAssertTrue(VersionSetSpecifier.range("0.0.1-latest" ..< "2.0.0").supportsPrereleases)
162+
XCTAssertTrue(VersionSetSpecifier.range("0.0.1" ..< "2.0.0-latest").supportsPrereleases)
163+
164+
XCTAssertTrue(VersionSetSpecifier.ranges([
165+
"0.0.1" ..< "0.0.2",
166+
"0.0.1" ..< "2.0.0-latest",
167+
]).supportsPrereleases)
168+
169+
XCTAssertTrue(VersionSetSpecifier.ranges([
170+
"0.0.1-latest" ..< "0.0.2",
171+
"0.0.1" ..< "2.0.0",
172+
]).supportsPrereleases)
173+
174+
XCTAssertFalse(VersionSetSpecifier.ranges([
175+
"0.0.1" ..< "0.0.2",
176+
"0.0.1" ..< "2.0.0",
177+
]).supportsPrereleases)
178+
}
154179
}

0 commit comments

Comments
 (0)