Skip to content

Commit 31800c2

Browse files
xedinbnbarham
authored andcommitted
[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. 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`. - 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. 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`. (cherry picked from commit 43fd240)
1 parent fa04bac commit 31800c2

File tree

5 files changed

+286
-2
lines changed

5 files changed

+286
-2
lines changed

Sources/PackageGraph/Resolution/PubGrub/PubGrubDependencyResolver.swift

+11-1
Original file line numberDiff line numberDiff line change
@@ -669,7 +669,17 @@ public struct PubGrubDependencyResolver {
669669
let start = DispatchTime.now()
670670
let counts = try result.get()
671671
// forced unwraps safe since we are testing for count and errors above
672-
let pkgTerm = undecided.min { counts[$0]! < counts[$1]! }!
672+
let pkgTerm = undecided.min {
673+
// Prefer packages that don't allow pre-release versions
674+
// to allow propagation logic to find dependencies that
675+
// limit the range before making any decisions. This means
676+
// that we'd always prefer release versions.
677+
if $0.supportsPrereleases != $1.supportsPrereleases {
678+
return !$0.supportsPrereleases
679+
}
680+
681+
return counts[$0]! < counts[$1]!
682+
}!
673683
self.delegate?.willResolve(term: pkgTerm)
674684
// at this point the container is cached
675685
let container = try self.provider.getCachedContainer(for: pkgTerm.node.package)

Sources/PackageGraph/Resolution/PubGrub/Term.swift

+35-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,39 @@ 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 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 }
139+
106140
}
107141
return true
108142
}

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
@@ -2887,6 +2887,158 @@ final class PubGrubBacktrackTests: XCTestCase {
28872887
XCTAssertTrue(observability.diagnostics.contains(where: { $0.message == "[DependencyResolver] resolved 'c' @ '1.5.2'" }))
28882888
XCTAssertTrue(observability.diagnostics.contains(where: { $0.message == "[DependencyResolver] resolved 'd' @ '2.3.0'" }))
28892889
}
2890+
2891+
func testPrereleaseVersionSelection() throws {
2892+
try builder.serve("a", at: "1.0.0", with: [
2893+
"a": [
2894+
"b": (.versionSet(.range("0.0.8"..<"2.0.0")), .specific(["b"]))
2895+
]
2896+
])
2897+
2898+
try builder.serve("b", at: "1.0.0-prerelease-20240710")
2899+
2900+
try builder.serve("c", at: "1.0.0", with: [
2901+
"c": [
2902+
"d": (.versionSet(.range("1.0.5"..<"2.0.0")), .specific(["d"]))
2903+
]
2904+
])
2905+
try builder.serve("c", at: "1.0.1")
2906+
2907+
try builder.serve("d", at: "1.0.0-prerelease-20240710")
2908+
try builder.serve("d", at: "1.0.6-prerelease-1")
2909+
try builder.serve("d", at: "1.0.6")
2910+
2911+
let resolver = builder.create()
2912+
// The order matters here because solver used to assign `b` before `a`.
2913+
let dependencies1 = try builder.create(dependencies: [
2914+
"a": (.versionSet(.exact("1.0.0")), .specific(["a"])),
2915+
"b": (.versionSet(.range("1.0.0-latest"..<"2.0.0")), .specific(["b"]))
2916+
])
2917+
2918+
AssertResult(resolver.solve(constraints: dependencies1), [
2919+
("a", .version("1.0.0")),
2920+
("b", .version("1.0.0-prerelease-20240710"))
2921+
])
2922+
2923+
let dependencies2 = try builder.create(dependencies: [
2924+
"c": (.versionSet(.exact("1.0.0")), .specific(["c"])),
2925+
"d": (.versionSet(.range("1.0.0-latest"..<"2.0.0")), .specific(["d"]))
2926+
])
2927+
2928+
AssertResult(resolver.solve(constraints: dependencies2), [
2929+
("c", .version("1.0.0")),
2930+
("d", .version("1.0.6"))
2931+
])
2932+
}
2933+
2934+
func testPrereleaseExactRequirement() throws {
2935+
try builder.serve("c", at: "1.0.0", with: [
2936+
"c": [
2937+
"d": (.versionSet(.range("1.0.4"..<"2.0.0")), .specific(["d"]))
2938+
]
2939+
])
2940+
try builder.serve("c", at: "1.0.1")
2941+
2942+
try builder.serve("d", at: "1.0.0-prerelease-20240710")
2943+
try builder.serve("d", at: "1.0.4")
2944+
try builder.serve("d", at: "1.0.6-prerelease-1")
2945+
2946+
let resolver = builder.create()
2947+
2948+
let exactDependencies = try builder.create(dependencies: [
2949+
"c": (.versionSet(.exact("1.0.0")), .specific(["c"])),
2950+
"d": (.versionSet(.exact("1.0.6-prerelease-1")), .specific(["d"]))
2951+
])
2952+
2953+
// FIXME: This should produce a valid solution but cannot at the
2954+
// moment because "1.0.4"..<"2.0.0" doesn't support pre-release versions
2955+
// and there is no way to infer it.
2956+
let resultWithExact = resolver.solve(constraints: exactDependencies)
2957+
XCTAssertMatch(
2958+
resultWithExact.errorMsg,
2959+
.contains("Dependencies could not be resolved because root depends on 'd' 1.0.6-prerelease-1")
2960+
)
2961+
2962+
let rangeDependency = try builder.create(dependencies: [
2963+
"c": (.versionSet(.exact("1.0.0")), .specific(["c"])),
2964+
"d": (.versionSet(.range("1.0.5"..<"1.0.6-prerelease-2")), .specific(["d"]))
2965+
])
2966+
2967+
let resultWithRange = resolver.solve(constraints: rangeDependency)
2968+
AssertResult(resultWithRange, [
2969+
("c", .version("1.0.0")),
2970+
("d", .version("1.0.6-prerelease-1"))
2971+
])
2972+
}
2973+
2974+
func testReleaseOverPrerelease() throws {
2975+
try builder.serve("a", at: "1.0.0", with: [
2976+
"a": [
2977+
"b": (.versionSet(.range("0.0.8"..<"2.0.0")), .specific(["b"]))
2978+
]
2979+
])
2980+
2981+
try builder.serve("b", at: "1.0.0-prerelease-20240616")
2982+
try builder.serve("b", at: "1.0.0")
2983+
2984+
let resolver = builder.create()
2985+
// The order matters here because solver used to assign `b` before `a`.
2986+
let dependencies1 = try builder.create(dependencies: [
2987+
"a": (.versionSet(.exact("1.0.0")), .specific(["a"])),
2988+
"b": (.versionSet(.range("1.0.0-latest"..<"2.0.0")), .specific(["b"]))
2989+
])
2990+
2991+
AssertResult(resolver.solve(constraints: dependencies1), [
2992+
("a", .version("1.0.0")),
2993+
("b", .version("1.0.0"))
2994+
])
2995+
2996+
let dependencies2 = try builder.create(dependencies: [
2997+
"b": (.versionSet(.range("1.0.0-latest"..<"2.0.0")), .specific(["b"])),
2998+
"a": (.versionSet(.exact("1.0.0")), .specific(["a"]))
2999+
])
3000+
3001+
AssertResult(resolver.solve(constraints: dependencies2), [
3002+
("a", .version("1.0.0")),
3003+
("b", .version("1.0.0"))
3004+
])
3005+
}
3006+
3007+
func testPrereleaseInferenceThroughDependencies() throws {
3008+
try builder.serve("a", at: "1.0.0", with: [
3009+
"a": [
3010+
"b": (.versionSet(.range("1.0.0"..<"2.0.0-latest")), .specific(["b"]))
3011+
]
3012+
])
3013+
3014+
try builder.serve("b", at: "0.0.8-prerelease-20230310")
3015+
try builder.serve("b", at: "0.0.8")
3016+
try builder.serve("b", at: "1.0.0-prerelease-20230616")
3017+
try builder.serve("b", at: "1.0.0")
3018+
try builder.serve("b", at: "1.9.9-prerelease-20240702")
3019+
3020+
let resolver = builder.create()
3021+
// The order matters here because solver used to assign `b` before `a`.
3022+
let dependencies1 = try builder.create(dependencies: [
3023+
"a": (.versionSet(.exact("1.0.0")), .specific(["a"])),
3024+
"b": (.versionSet(.range("0.0.8"..<"2.0.0")), .specific(["b"]))
3025+
])
3026+
3027+
AssertResult(resolver.solve(constraints: dependencies1), [
3028+
("a", .version("1.0.0")),
3029+
("b", .version("1.9.9-prerelease-20240702"))
3030+
])
3031+
3032+
let dependencies2 = try builder.create(dependencies: [
3033+
"b": (.versionSet(.range("0.0.8"..<"2.0.0")), .specific(["b"])),
3034+
"a": (.versionSet(.exact("1.0.0")), .specific(["a"]))
3035+
])
3036+
3037+
AssertResult(resolver.solve(constraints: dependencies2), [
3038+
("a", .version("1.0.0")),
3039+
("b", .version("1.9.9-prerelease-20240702"))
3040+
])
3041+
}
28903042
}
28913043

28923044
fileprivate 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)