Skip to content

Commit c9af997

Browse files
committed
[PubGrub] Relax isValidDecision check to handle prerelease versions correctly
The intersection between release and pre-release ranges is allowed to produce a pre-release range which means that the solver is allowed to make a pre-release version decision even when some of the versions didn't allow pre-releases.
1 parent 80dc78c commit c9af997

File tree

3 files changed

+220
-1
lines changed

3 files changed

+220
-1
lines changed

Sources/PackageGraph/Resolution/PubGrub/Term.swift

+30-1
Original file line numberDiff line numberDiff line change
@@ -104,9 +104,38 @@ public struct Term: Equatable, Hashable {
104104
/// - There has to be no decision for it.
105105
/// - The package version has to match all assignments.
106106
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 which 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+
107121
for assignment in solution.assignments where assignment.term.node == self.node {
108122
assert(!assignment.isDecision, "Expected assignment to be a derivation.")
109-
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 but 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 }
110139
}
111140
return true
112141
}

Sources/PackageGraph/VersionSetSpecifier.swift

+38
Original file line numberDiff line numberDiff line change
@@ -479,6 +479,23 @@ extension VersionSetSpecifier {
479479
ranges.contains(where: \.supportsPrereleases)
480480
}
481481
}
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+
}
482499
}
483500

484501
extension VersionSetSpecifier: CustomStringConvertible {
@@ -524,10 +541,31 @@ fileprivate extension Range where Bound == Version {
524541
var supportsPrereleases: Bool {
525542
self.lowerBound.supportsPrerelease || self.upperBound.supportsPrerelease
526543
}
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+
}
527555
}
528556

529557
fileprivate extension Version {
530558
var supportsPrerelease: Bool {
531559
!self.prereleaseIdentifiers.isEmpty
532560
}
561+
562+
var withoutPrerelease: Version {
563+
Version(
564+
self.major,
565+
self.minor,
566+
self.patch,
567+
prereleaseIdentifiers: [],
568+
buildMetadataIdentifiers: self.buildMetadataIdentifiers
569+
)
570+
}
533571
}

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 {

0 commit comments

Comments
 (0)