From 5d6174a8487f36d35259ab61860d1545288741d5 Mon Sep 17 00:00:00 2001 From: Oliver Heger Date: Tue, 9 Jun 2026 12:58:15 +0200 Subject: [PATCH 1/2] fix(yarn2): Deal with version mismatches It has been observed that packages in the dependency graph reference other packages with a different version than was actually installed. The current implementation could not handle this correctly, but failed to look up the `PackageInfo` for affected packages. Fix this by implementing fallback logic for virtual packages and packages with changed versions. Signed-off-by: Oliver Heger --- .../kotlin/yarn2/Yarn2DependencyHandler.kt | 57 ++++++- .../yarn2/Yarn2DependencyHandlerTest.kt | 145 ++++++++++++++++++ 2 files changed, 201 insertions(+), 1 deletion(-) diff --git a/plugins/package-managers/node/src/main/kotlin/yarn2/Yarn2DependencyHandler.kt b/plugins/package-managers/node/src/main/kotlin/yarn2/Yarn2DependencyHandler.kt index faa63574fc5dc..de9457ec8443f 100644 --- a/plugins/package-managers/node/src/main/kotlin/yarn2/Yarn2DependencyHandler.kt +++ b/plugins/package-managers/node/src/main/kotlin/yarn2/Yarn2DependencyHandler.kt @@ -21,6 +21,8 @@ package org.ossreviewtoolkit.plugins.packagemanagers.node.yarn2 import java.io.File +import org.apache.logging.log4j.kotlin.logger + import org.ossreviewtoolkit.model.Identifier import org.ossreviewtoolkit.model.Issue import org.ossreviewtoolkit.model.Package @@ -65,7 +67,7 @@ internal class Yarn2DependencyHandler( ) override fun dependenciesFor(dependency: PackageInfo): List = - dependency.children.dependencies.map { packageInfoForLocator.getValue(it.realLocator) } + dependency.children.dependencies.map(this::packageInfoFor) override fun linkageFor(dependency: PackageInfo): PackageLinkage = if (dependency.isProject) PackageLinkage.PROJECT_DYNAMIC else PackageLinkage.DYNAMIC @@ -76,11 +78,62 @@ internal class Yarn2DependencyHandler( return parsePackage(packageJson, moduleInfoResolver) } + + /** + * Obtain the [PackageInfo] object for the given [dependency]. + * + * Try the `realLocator` first to correctly handle virtual packages. If that fails, try to construct the real + * locator from the virtual package's actual resolved version (handles virtual packages whose `children.version` + * was overridden by Yarn's `resolutions` feature). + * + * If both targeted lookups fail, fall back to searching the map for all installed versions of the same module + * by name. This handles the case where Yarn's `resolutions` feature (or similar mechanisms) cause a non-virtual + * dependency locator to reference a version that is not present in the map, while a different version of the + * same module was actually installed. If exactly one candidate is found, it is used. If multiple candidates are + * found, the resolution is ambiguous and an exception is thrown. + */ + internal fun packageInfoFor(dependency: PackageInfo.Dependency): PackageInfo { + packageInfoForLocator[dependency.realLocator]?.let { return it } + + // Fallback for virtual packages: derive the real locator from the virtual package's resolved version. + packageInfoForLocator[dependency.locator]?.let { virtualInfo -> + val moduleName = Locator.parse(dependency.locator).moduleName + packageInfoForLocator["$moduleName@npm:${virtualInfo.children.version}"]?.let { return it } + } + + // Fallback for version mismatches caused by Yarn's `resolutions` feature: find the single installed version + // of the same module by name, ignoring the exact version in the locator. + val moduleName = Locator.parse(dependency.realLocator).moduleName + val candidates = packageInfoForLocator.values.filter { + it.moduleName == moduleName && !it.isProject && !it.isVirtual + } + + return when (candidates.size) { + 1 -> candidates.single().also { + logger.debug { + "Resolved locator '${dependency.realLocator}' to '${it.value}' via module name lookup." + } + } + + 0 -> error( + "Could not find a PackageInfo for locator '${dependency.realLocator}'. No entry for module " + + "'$moduleName' exists in ${packageInfoForLocator.keys}." + ) + + else -> error( + "Could not unambiguously resolve locator '${dependency.realLocator}'. Found ${candidates.size} " + + "installed versions of module '$moduleName': ${candidates.map { it.value }}." + ) + } + } } internal val PackageInfo.isProject: Boolean get() = Locator.parse(value).isProject +internal val PackageInfo.isVirtual: Boolean + get() = Locator.parse(value).isVirtual + internal val PackageInfo.moduleName: String // TODO: Handle patched packages different than non-patched ones. // Patch packages have locators as e.g. the following, where the first component ends with "@patch". @@ -113,4 +166,6 @@ internal data class Locator( val isProject: Boolean = remainder.startsWith("workspace:") || (remainder.startsWith("virtual:") && "#workspace:" in remainder) + + val isVirtual: Boolean = remainder.startsWith("virtual:") && !isProject } diff --git a/plugins/package-managers/node/src/test/kotlin/yarn2/Yarn2DependencyHandlerTest.kt b/plugins/package-managers/node/src/test/kotlin/yarn2/Yarn2DependencyHandlerTest.kt index 9a7779719da51..c8d585d80df1f 100644 --- a/plugins/package-managers/node/src/test/kotlin/yarn2/Yarn2DependencyHandlerTest.kt +++ b/plugins/package-managers/node/src/test/kotlin/yarn2/Yarn2DependencyHandlerTest.kt @@ -19,8 +19,14 @@ package org.ossreviewtoolkit.plugins.packagemanagers.node.yarn2 +import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.WordSpec import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain + +import java.io.File + +import org.ossreviewtoolkit.plugins.packagemanagers.node.ModuleInfoResolver class Yarn2DependencyHandlerTest : WordSpec({ "Locator.parse()" should { @@ -59,4 +65,143 @@ class Yarn2DependencyHandlerTest : WordSpec({ locator.isProject shouldBe true } } + + "Locator.isVirtual" should { + "return true for a virtual npm package" { + val locator = Locator.parse( + "cookie@virtual:abc123def456abc123def456abc123def456abc123def456abc123def456abc123de#npm:1.0.2" + ) + + locator.isVirtual shouldBe true + } + + "return false for a real npm package" { + Locator.parse("cookie@npm:1.0.2").isVirtual shouldBe false + } + + "return false for a workspace project" { + Locator.parse("myapp@workspace:.").isVirtual shouldBe false + } + + "return false for a virtual workspace project" { + val locator = Locator.parse( + "@failing/package-with-lightningcss@virtual:f87a972e7ee54256c6d8f979d7f3914b32522893226eba595e4ef" + + "e4ecc641a239c6d88e01eccc6f32db30829d6ac493bfc98cb406a9b0d6059ee4112c084" + + "3da9#workspace:packages/spark" + ) + + locator.isVirtual shouldBe false + } + } + + "packageInfoFor()" should { + "resolve a dependency via its realLocator" { + val info = packageInfo("cookie@npm:1.0.2", "1.0.2") + val handler = handlerWith(mapOf("cookie@npm:1.0.2" to info)) + + handler.packageInfoFor(dep("cookie@npm:1.0.2")) shouldBe info + } + + "resolve a virtual dependency via its realLocator" { + val info = packageInfo("cookie@npm:1.0.2", "1.0.2") + val virtualLocator = + "cookie@virtual:abc123def456abc123def456abc123def456abc123def456abc123def456abc123de#npm:1.0.2" + val handler = handlerWith(mapOf("cookie@npm:1.0.2" to info)) + + handler.packageInfoFor(dep(virtualLocator)) shouldBe info + } + + "use the virtual package fallback when the realLocator is not in the map" { + // This handles virtual packages whose children.version was overridden by Yarn's resolutions feature. + // The virtual locator encodes version 1.0.2, but children.version reflects the resolved version 1.1.1. + val virtualLocator = + "cookie@virtual:abc123def456abc123def456abc123def456abc123def456abc123def456abc123de#npm:1.0.2" + val virtualInfo = packageInfo(virtualLocator, "1.1.1") + val resolvedInfo = packageInfo("cookie@npm:1.1.1", "1.1.1") + val handler = handlerWith( + mapOf( + virtualLocator to virtualInfo, + "cookie@npm:1.1.1" to resolvedInfo + ) + ) + + handler.packageInfoFor(dep(virtualLocator)) shouldBe resolvedInfo + } + + "use the module name fallback when only a different version is installed" { + // This handles the case where Yarn's resolutions feature causes a non-virtual dependency locator + // to reference a version that is not present in the map. + val resolvedInfo = packageInfo("cookie@npm:1.1.1", "1.1.1") + val handler = handlerWith(mapOf("cookie@npm:1.1.1" to resolvedInfo)) + + handler.packageInfoFor(dep("cookie@npm:1.0.2")) shouldBe resolvedInfo + } + + "ignore virtual packages in the module name fallback" { + val virtualLocator = + "cookie@virtual:abc123def456abc123def456abc123def456abc123def456abc123def456abc123de#npm:1.1.1" + val resolvedInfo = packageInfo("cookie@npm:1.1.1", "1.1.1") + val virtualInfo = packageInfo(virtualLocator, "1.1.1") + val handler = handlerWith( + mapOf( + "cookie@npm:1.1.1" to resolvedInfo, + virtualLocator to virtualInfo + ) + ) + + handler.packageInfoFor(dep("cookie@npm:1.0.2")) shouldBe resolvedInfo + } + + "throw when no entry for the module is found" { + val handler = handlerWith(mapOf("other@npm:1.0.0" to packageInfo("other@npm:1.0.0", "1.0.0"))) + + val exception = shouldThrow { + handler.packageInfoFor(dep("cookie@npm:1.0.2")) + } + + exception.message shouldContain "cookie" + } + + "throw when multiple real versions of the module are found" { + val handler = handlerWith( + mapOf( + "cookie@npm:1.0.0" to packageInfo("cookie@npm:1.0.0", "1.0.0"), + "cookie@npm:1.1.1" to packageInfo("cookie@npm:1.1.1", "1.1.1") + ) + ) + + val exception = shouldThrow { + handler.packageInfoFor(dep("cookie@npm:1.0.2")) + } + + exception.message shouldContain "2" + exception.message shouldContain "cookie" + } + } }) + +private val WORKING_DIR = File(".") + +/** + * Create a minimal [PackageInfo] for the given [locator] and [version]. + */ +private fun packageInfo(locator: String, version: String, deps: List = emptyList()) = + PackageInfo( + value = locator, + children = PackageInfo.Children(version = version, dependencies = deps) + ) + +/** + * Create a [PackageInfo.Dependency] with the given [locator]. The descriptor is set to a dummy value. + */ +private fun dep(locator: String) = PackageInfo.Dependency(descriptor = "dummy", locator = locator) + +/** + * Create a [Yarn2DependencyHandler] with the given [packageInfoForLocator] map set via + * [Yarn2DependencyHandler.setContext]. + */ +private fun handlerWith(packageInfoForLocator: Map): Yarn2DependencyHandler { + val resolver = ModuleInfoResolver { _, _ -> emptySet() } + resolver.workingDir = WORKING_DIR + return Yarn2DependencyHandler(resolver).apply { setContext(WORKING_DIR, emptyMap(), packageInfoForLocator) } +} From 81d9b7c36bd9532c98620255822b13bb97911c3f Mon Sep 17 00:00:00 2001 From: Oliver Heger Date: Wed, 10 Jun 2026 06:59:35 +0200 Subject: [PATCH 2/2] 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 --- .../kotlin/yarn2/Yarn2DependencyHandler.kt | 65 +++++++++++++++---- .../yarn2/Yarn2DependencyHandlerTest.kt | 29 ++++++++- 2 files changed, 79 insertions(+), 15 deletions(-) diff --git a/plugins/package-managers/node/src/main/kotlin/yarn2/Yarn2DependencyHandler.kt b/plugins/package-managers/node/src/main/kotlin/yarn2/Yarn2DependencyHandler.kt index de9457ec8443f..63342a9766db5 100644 --- a/plugins/package-managers/node/src/main/kotlin/yarn2/Yarn2DependencyHandler.kt +++ b/plugins/package-managers/node/src/main/kotlin/yarn2/Yarn2DependencyHandler.kt @@ -33,6 +33,9 @@ import org.ossreviewtoolkit.plugins.packagemanagers.node.NodePackageManagerType import org.ossreviewtoolkit.plugins.packagemanagers.node.PackageJson import org.ossreviewtoolkit.plugins.packagemanagers.node.parsePackage +import org.semver4j.Semver +import org.semver4j.range.RangeListFactory + internal class Yarn2DependencyHandler( private val moduleInfoResolver: ModuleInfoResolver ) : DependencyHandler { @@ -86,13 +89,15 @@ internal class Yarn2DependencyHandler( * locator from the virtual package's actual resolved version (handles virtual packages whose `children.version` * was overridden by Yarn's `resolutions` feature). * - * If both targeted lookups fail, fall back to searching the map for all installed versions of the same module - * by name. This handles the case where Yarn's `resolutions` feature (or similar mechanisms) cause a non-virtual - * dependency locator to reference a version that is not present in the map, while a different version of the - * same module was actually installed. If exactly one candidate is found, it is used. If multiple candidates are - * found, the resolution is ambiguous and an exception is thrown. + * If both targeted lookups fail, fall back to searching the map for all installed non-virtual, non-project + * versions of the same module by name. This handles the case where Yarn's `resolutions` feature (or similar + * mechanisms) cause a non-virtual dependency locator to reference a version that is not present in the map, + * while a different version of the same module was actually installed. If exactly one candidate is found, it + * is used. If multiple candidates are found, the semver range from the [dependency]'s descriptor is used to + * narrow down the candidates. If after all fallbacks the result is still not unique, an exception is thrown. */ internal fun packageInfoFor(dependency: PackageInfo.Dependency): PackageInfo { + // Direct lookup by the real locator. packageInfoForLocator[dependency.realLocator]?.let { return it } // Fallback for virtual packages: derive the real locator from the virtual package's resolved version. @@ -101,30 +106,34 @@ internal class Yarn2DependencyHandler( packageInfoForLocator["$moduleName@npm:${virtualInfo.children.version}"]?.let { return it } } - // Fallback for version mismatches caused by Yarn's `resolutions` feature: find the single installed version - // of the same module by name, ignoring the exact version in the locator. + // Fallback for version mismatches caused by Yarn's `resolutions` feature: find installed versions of the + // same module by name, ignoring the exact version in the locator. val moduleName = Locator.parse(dependency.realLocator).moduleName val candidates = packageInfoForLocator.values.filter { it.moduleName == moduleName && !it.isProject && !it.isVirtual } - return when (candidates.size) { - 1 -> candidates.single().also { + if (candidates.size == 1) { + return candidates.single().also { logger.debug { "Resolved locator '${dependency.realLocator}' to '${it.value}' via module name lookup." } } + } - 0 -> error( - "Could not find a PackageInfo for locator '${dependency.realLocator}'. No entry for module " + - "'$moduleName' exists in ${packageInfoForLocator.keys}." - ) + if (candidates.size > 1) { + candidates.matchVersionRange(dependency)?.let { return it } - else -> error( + error( "Could not unambiguously resolve locator '${dependency.realLocator}'. Found ${candidates.size} " + "installed versions of module '$moduleName': ${candidates.map { it.value }}." ) } + + error( + "Could not find a PackageInfo for locator '${dependency.realLocator}'. No entry for module " + + "'$moduleName' exists in ${packageInfoForLocator.keys}." + ) } } @@ -169,3 +178,31 @@ internal data class Locator( val isVirtual: Boolean = remainder.startsWith("virtual:") && !isProject } + +/** + * Try to find a single [PackageInfo] from this collection that matches the given [dependency] taking semantic + * version ranges into account. + */ +private fun Collection.matchVersionRange(dependency: PackageInfo.Dependency): PackageInfo? { + val descriptorRemainder = Locator.parse(dependency.descriptor).remainder + if (descriptorRemainder.startsWith("npm:")) { + val rangeSpec = descriptorRemainder.removePrefix("npm:") + val range = runCatching { RangeListFactory.create(rangeSpec) }.getOrNull() + if (range != null) { + val matchingCandidates = filter { candidate -> + Semver.coerce(candidate.children.version)?.let { range.isSatisfiedBy(it) } == true + } + + if (matchingCandidates.size == 1) { + return matchingCandidates.single().also { + logger.debug { + "Resolved locator '${dependency.realLocator}' to '${it.value}' via semver range " + + "matching on descriptor '${dependency.descriptor}'." + } + } + } + } + } + + return null +} diff --git a/plugins/package-managers/node/src/test/kotlin/yarn2/Yarn2DependencyHandlerTest.kt b/plugins/package-managers/node/src/test/kotlin/yarn2/Yarn2DependencyHandlerTest.kt index c8d585d80df1f..9fdc6e96b829b 100644 --- a/plugins/package-managers/node/src/test/kotlin/yarn2/Yarn2DependencyHandlerTest.kt +++ b/plugins/package-managers/node/src/test/kotlin/yarn2/Yarn2DependencyHandlerTest.kt @@ -177,6 +177,33 @@ class Yarn2DependencyHandlerTest : WordSpec({ exception.message shouldContain "2" exception.message shouldContain "cookie" } + + "use the semver range from the descriptor to disambiguate multiple candidates" { + // Two real versions are installed. The descriptor's range matches only one of them. + val info100 = packageInfo("cookie@npm:1.0.0", "1.0.0") + val info200 = packageInfo("cookie@npm:2.0.0", "2.0.0") + // Descriptor "cookie@npm:^1.0.0" matches 1.0.0 but not 2.0.0. + val dependency = PackageInfo.Dependency(descriptor = "cookie@npm:^1.0.0", locator = "cookie@npm:1.0.2") + val handler = handlerWith(mapOf("cookie@npm:1.0.0" to info100, "cookie@npm:2.0.0" to info200)) + + handler.packageInfoFor(dependency) shouldBe info100 + } + + "throw when the semver range from the descriptor still matches multiple candidates" { + // Both installed versions satisfy the descriptor range. + val info440 = packageInfo("debug@npm:4.4.0", "4.4.0") + val info443 = packageInfo("debug@npm:4.4.3", "4.4.3") + // Descriptor "debug@npm:^4.3.0" matches both 4.4.0 and 4.4.3. + val dependency = PackageInfo.Dependency(descriptor = "debug@npm:^4.3.0", locator = "debug@npm:4.3.6") + val handler = handlerWith(mapOf("debug@npm:4.4.0" to info440, "debug@npm:4.4.3" to info443)) + + val exception = shouldThrow { + handler.packageInfoFor(dependency) + } + + exception.message shouldContain "2" + exception.message shouldContain "debug" + } } }) @@ -194,7 +221,7 @@ private fun packageInfo(locator: String, version: String, deps: List