Skip to content

Commit 81d9b7c

Browse files
committed
fix(yarn2): Handle dependencies with multiple versions
Handle the case that a package appears multiple times with different versions in the dependency graph. In this case, try to select the correct version based on semantic version ranges. Signed-off-by: Oliver Heger <oliver.heger@bosch.com>
1 parent 5d6174a commit 81d9b7c

2 files changed

Lines changed: 79 additions & 15 deletions

File tree

plugins/package-managers/node/src/main/kotlin/yarn2/Yarn2DependencyHandler.kt

Lines changed: 51 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ import org.ossreviewtoolkit.plugins.packagemanagers.node.NodePackageManagerType
3333
import org.ossreviewtoolkit.plugins.packagemanagers.node.PackageJson
3434
import org.ossreviewtoolkit.plugins.packagemanagers.node.parsePackage
3535

36+
import org.semver4j.Semver
37+
import org.semver4j.range.RangeListFactory
38+
3639
internal class Yarn2DependencyHandler(
3740
private val moduleInfoResolver: ModuleInfoResolver
3841
) : DependencyHandler<PackageInfo> {
@@ -86,13 +89,15 @@ internal class Yarn2DependencyHandler(
8689
* locator from the virtual package's actual resolved version (handles virtual packages whose `children.version`
8790
* was overridden by Yarn's `resolutions` feature).
8891
*
89-
* If both targeted lookups fail, fall back to searching the map for all installed versions of the same module
90-
* by name. This handles the case where Yarn's `resolutions` feature (or similar mechanisms) cause a non-virtual
91-
* dependency locator to reference a version that is not present in the map, while a different version of the
92-
* same module was actually installed. If exactly one candidate is found, it is used. If multiple candidates are
93-
* found, the resolution is ambiguous and an exception is thrown.
92+
* If both targeted lookups fail, fall back to searching the map for all installed non-virtual, non-project
93+
* versions of the same module by name. This handles the case where Yarn's `resolutions` feature (or similar
94+
* mechanisms) cause a non-virtual dependency locator to reference a version that is not present in the map,
95+
* while a different version of the same module was actually installed. If exactly one candidate is found, it
96+
* is used. If multiple candidates are found, the semver range from the [dependency]'s descriptor is used to
97+
* narrow down the candidates. If after all fallbacks the result is still not unique, an exception is thrown.
9498
*/
9599
internal fun packageInfoFor(dependency: PackageInfo.Dependency): PackageInfo {
100+
// Direct lookup by the real locator.
96101
packageInfoForLocator[dependency.realLocator]?.let { return it }
97102

98103
// Fallback for virtual packages: derive the real locator from the virtual package's resolved version.
@@ -101,30 +106,34 @@ internal class Yarn2DependencyHandler(
101106
packageInfoForLocator["$moduleName@npm:${virtualInfo.children.version}"]?.let { return it }
102107
}
103108

104-
// Fallback for version mismatches caused by Yarn's `resolutions` feature: find the single installed version
105-
// of the same module by name, ignoring the exact version in the locator.
109+
// Fallback for version mismatches caused by Yarn's `resolutions` feature: find installed versions of the
110+
// same module by name, ignoring the exact version in the locator.
106111
val moduleName = Locator.parse(dependency.realLocator).moduleName
107112
val candidates = packageInfoForLocator.values.filter {
108113
it.moduleName == moduleName && !it.isProject && !it.isVirtual
109114
}
110115

111-
return when (candidates.size) {
112-
1 -> candidates.single().also {
116+
if (candidates.size == 1) {
117+
return candidates.single().also {
113118
logger.debug {
114119
"Resolved locator '${dependency.realLocator}' to '${it.value}' via module name lookup."
115120
}
116121
}
122+
}
117123

118-
0 -> error(
119-
"Could not find a PackageInfo for locator '${dependency.realLocator}'. No entry for module " +
120-
"'$moduleName' exists in ${packageInfoForLocator.keys}."
121-
)
124+
if (candidates.size > 1) {
125+
candidates.matchVersionRange(dependency)?.let { return it }
122126

123-
else -> error(
127+
error(
124128
"Could not unambiguously resolve locator '${dependency.realLocator}'. Found ${candidates.size} " +
125129
"installed versions of module '$moduleName': ${candidates.map { it.value }}."
126130
)
127131
}
132+
133+
error(
134+
"Could not find a PackageInfo for locator '${dependency.realLocator}'. No entry for module " +
135+
"'$moduleName' exists in ${packageInfoForLocator.keys}."
136+
)
128137
}
129138
}
130139

@@ -169,3 +178,31 @@ internal data class Locator(
169178

170179
val isVirtual: Boolean = remainder.startsWith("virtual:") && !isProject
171180
}
181+
182+
/**
183+
* Try to find a single [PackageInfo] from this collection that matches the given [dependency] taking semantic
184+
* version ranges into account.
185+
*/
186+
private fun Collection<PackageInfo>.matchVersionRange(dependency: PackageInfo.Dependency): PackageInfo? {
187+
val descriptorRemainder = Locator.parse(dependency.descriptor).remainder
188+
if (descriptorRemainder.startsWith("npm:")) {
189+
val rangeSpec = descriptorRemainder.removePrefix("npm:")
190+
val range = runCatching { RangeListFactory.create(rangeSpec) }.getOrNull()
191+
if (range != null) {
192+
val matchingCandidates = filter { candidate ->
193+
Semver.coerce(candidate.children.version)?.let { range.isSatisfiedBy(it) } == true
194+
}
195+
196+
if (matchingCandidates.size == 1) {
197+
return matchingCandidates.single().also {
198+
logger.debug {
199+
"Resolved locator '${dependency.realLocator}' to '${it.value}' via semver range " +
200+
"matching on descriptor '${dependency.descriptor}'."
201+
}
202+
}
203+
}
204+
}
205+
}
206+
207+
return null
208+
}

plugins/package-managers/node/src/test/kotlin/yarn2/Yarn2DependencyHandlerTest.kt

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,33 @@ class Yarn2DependencyHandlerTest : WordSpec({
177177
exception.message shouldContain "2"
178178
exception.message shouldContain "cookie"
179179
}
180+
181+
"use the semver range from the descriptor to disambiguate multiple candidates" {
182+
// Two real versions are installed. The descriptor's range matches only one of them.
183+
val info100 = packageInfo("cookie@npm:1.0.0", "1.0.0")
184+
val info200 = packageInfo("cookie@npm:2.0.0", "2.0.0")
185+
// Descriptor "cookie@npm:^1.0.0" matches 1.0.0 but not 2.0.0.
186+
val dependency = PackageInfo.Dependency(descriptor = "cookie@npm:^1.0.0", locator = "cookie@npm:1.0.2")
187+
val handler = handlerWith(mapOf("cookie@npm:1.0.0" to info100, "cookie@npm:2.0.0" to info200))
188+
189+
handler.packageInfoFor(dependency) shouldBe info100
190+
}
191+
192+
"throw when the semver range from the descriptor still matches multiple candidates" {
193+
// Both installed versions satisfy the descriptor range.
194+
val info440 = packageInfo("debug@npm:4.4.0", "4.4.0")
195+
val info443 = packageInfo("debug@npm:4.4.3", "4.4.3")
196+
// Descriptor "debug@npm:^4.3.0" matches both 4.4.0 and 4.4.3.
197+
val dependency = PackageInfo.Dependency(descriptor = "debug@npm:^4.3.0", locator = "debug@npm:4.3.6")
198+
val handler = handlerWith(mapOf("debug@npm:4.4.0" to info440, "debug@npm:4.4.3" to info443))
199+
200+
val exception = shouldThrow<IllegalStateException> {
201+
handler.packageInfoFor(dependency)
202+
}
203+
204+
exception.message shouldContain "2"
205+
exception.message shouldContain "debug"
206+
}
180207
}
181208
})
182209

@@ -194,7 +221,7 @@ private fun packageInfo(locator: String, version: String, deps: List<PackageInfo
194221
/**
195222
* Create a [PackageInfo.Dependency] with the given [locator]. The descriptor is set to a dummy value.
196223
*/
197-
private fun dep(locator: String) = PackageInfo.Dependency(descriptor = "dummy", locator = locator)
224+
private fun dep(locator: String) = PackageInfo.Dependency(descriptor = "dummy@npm:^1.2.3", locator = locator)
198225

199226
/**
200227
* Create a [Yarn2DependencyHandler] with the given [packageInfoForLocator] map set via

0 commit comments

Comments
 (0)