[2.0.x] fix: Unresolved dependency error for Coursier (#8869) (#8961)

Fixes unresolved dependency path reporting for Coursier (`ResolveException.failedPaths`) and adds a stable scripted regression.

This PR addresses 5168 by reconstructing unresolved dependency caller chains from the Coursier resolution graph and attaching them to `ResolveException.failedPaths`. That allows unresolved warnings to show the full path from the missing module up to the root project.

It also adds and stabilizes `lm-coursier/unresolved-path` scripted coverage by:
- using a local test Maven repo fixture (no flaky remote test dependency)
- checking update stream output via an sbt task (`checkLog`) instead of shell `grep`
- asserting the unresolved path includes missing module, transitive caller, and root project

Co-authored-by: bitloi <89318445+bitloi@users.noreply.github.com>
This commit is contained in:
eugene yokota 2026-03-22 21:53:20 -04:00 committed by GitHub
parent 023d3ba2c5
commit db41205098
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 242 additions and 4 deletions

View File

@ -29,6 +29,7 @@ import sbt.internal.librarymanagement.IvySbt
import sbt.librarymanagement.*
import sbt.util.Logger
import coursier.core.{ BomDependency, Dependency, Publication }
import scala.util.control.NonFatal
import scala.util.{ Try, Failure }
@ -399,10 +400,110 @@ class CoursierDependencyResolution(
}
report
}
e.left.map(unresolvedWarningOrThrow(uwconfig, _))
e.left.map(unresolvedWarningOrThrow(module0.module, uwconfig, _))
}
private def toModuleId(module: coursier.core.Module, version: String): ModuleID =
ModuleID(module.organization.value, module.name.value, version)
.withExtraAttributes(module.attributes)
private type DependencyKey = (coursier.core.Module, String)
private def dependencyKey(dependency: Dependency): DependencyKey =
dependency.module -> dependency.version
private def sortDependencies(dependencies: Seq[Dependency]): Vector[Dependency] =
dependencies.toVector.sortBy { dep =>
(dep.module.organization.value, dep.module.name.value, dep.version)
}
private def safeDependenciesOf(
resolution: Resolution,
dependency: Dependency
): Vector[Dependency] =
try sortDependencies(resolution.dependenciesOf(dependency, false, false))
catch {
case NonFatal(_) => Vector.empty
}
private def pathScore(path: Vector[Dependency]): (Int, String) =
path.size -> path
.map(dep => s"${dep.module.organization.value}:${dep.module.name.value}:${dep.version}")
.mkString("->")
private def betterPath(
candidate: Vector[Dependency],
currentBest: Option[Vector[Dependency]]
): Option[Vector[Dependency]] =
currentBest match {
case Some(best) =>
val (bestLength, bestPathStr) = pathScore(best)
val (candidateLength, candidatePathStr) = pathScore(candidate)
if (
bestLength > candidateLength || (bestLength == candidateLength && bestPathStr >= candidatePathStr)
) currentBest
else Some(candidate)
case _ => Some(candidate)
}
private def longestPathToTarget(
resolution: Resolution,
current: Dependency,
target: DependencyKey,
seen: Set[DependencyKey]
): Option[Vector[Dependency]] = {
val currentKey = dependencyKey(current)
if (currentKey == target) Some(Vector(current))
else {
safeDependenciesOf(resolution, current).iterator
.filterNot(dep => seen(dependencyKey(dep)))
.foldLeft(Option.empty[Vector[Dependency]]) { (best, dep) =>
val key = dependencyKey(dep)
val candidate = longestPathToTarget(resolution, dep, target, seen + key).map { tail =>
current +: tail
}
candidate match {
case Some(path) => betterPath(path, best)
case None => best
}
}
}
}
private def resolvePath(
resolution: Resolution,
failedDependency: Dependency,
rootModule: ModuleID
): Seq[ModuleID] = {
val normalizedRootModule = rootModule.withConfigurations(None)
val roots = sortDependencies(resolution.rootDependencies)
val target = dependencyKey(failedDependency)
val resolvedPath = roots
.foldLeft(Option.empty[Vector[Dependency]]) { (best, root) =>
val candidate = longestPathToTarget(resolution, root, target, Set(dependencyKey(root)))
candidate match {
case Some(path) => betterPath(path, best)
case None => best
}
}
.getOrElse(Vector(failedDependency))
normalizedRootModule +: resolvedPath.map(dep => toModuleId(dep.module, dep.version))
}
private def failedPaths(
rootModule: ModuleID,
resolution: Resolution,
downloadErrors: Seq[coursier.error.ResolutionError.CantDownloadModule]
): Map[ModuleID, Seq[ModuleID]] =
downloadErrors.map { err =>
val failedDependency = Dependency(err.module, err.version)
val failedModule = toModuleId(err.module, err.version)
failedModule -> resolvePath(resolution, failedDependency, rootModule)
}.toMap
private def unresolvedWarningOrThrow(
rootModule: ModuleID,
uwconfig: UnresolvedWarningConfiguration,
ex: coursier.error.CoursierError
): UnresolvedWarning = {
@ -428,12 +529,17 @@ class CoursierDependencyResolution(
}
if (otherErrors.isEmpty) {
val resolution = ex match {
case ex0: coursier.error.ResolutionError => ex0.resolution
case _ => Resolution()
}
val resolvedPaths = failedPaths(rootModule, resolution, downloadErrors)
val r = new ResolveException(
downloadErrors.map(_.getMessage),
downloadErrors.map { err =>
ModuleID(err.module.organization.value, err.module.name.value, err.version)
.withExtraAttributes(err.module.attributes)
}
toModuleId(err.module, err.version)
},
resolvedPaths
)
UnresolvedWarning(r, uwconfig)
} else

View File

@ -7,6 +7,7 @@ import sbt.internal.util.ConsoleLogger
import sbt.librarymanagement.*
import sbt.librarymanagement.Configurations.Component
import sbt.librarymanagement.Resolver.{ DefaultMavenRepository, JavaNet2Repository }
import sbt.util.ShowLines.*
// import sbt.librarymanagement.{ Resolver, UnresolvedWarningConfiguration, UpdateConfiguration }
import sbt.librarymanagement.syntax.*
@ -50,6 +51,20 @@ final class ResolutionSpec extends AnyPropSpec with Matchers {
private final val stubModule = "com.example" % "foo" % "0.1.0" % "compile"
private def unresolvedWarningLines(module: ModuleDescriptor): Seq[String] =
lmEngine.update(module, UpdateConfiguration(), UnresolvedWarningConfiguration(), log) match {
case Left(uw) => uw.lines
case Right(report) =>
fail(s"Expected resolution to fail, but it succeeded with report: $report")
}
private def assertContainsAll(lines: Seq[String], expected: Seq[String]): Unit =
expected.foreach { line =>
withClue(lines.mkString("\n")) {
lines should contain(line)
}
}
property("very simple module") {
val dependencies = Vector(
"com.typesafe.scala-logging" % "scala-logging_2.12" % "3.7.2" % "compile",
@ -88,6 +103,52 @@ final class ResolutionSpec extends AnyPropSpec with Matchers {
componentConfig.modules.head.artifacts.head._1.classifier should contain("sources")
}
property("unresolved warning includes transitive caller graph for Coursier") {
val dependencies =
Vector("org.apache.cayenne.plugins" % "maven-cayenne-plugin" % "3.0.2" % "compile")
val unresolvedModule = module(
lmEngine,
stubModule.withRevision("0.2.0"),
dependencies,
Some("2.12.4")
)
val lines = unresolvedWarningLines(unresolvedModule)
assertContainsAll(
lines,
Seq(
"\n\tNote: Unresolved dependencies path:",
"\t\tfoundrylogic.vpp:vpp:2.2.1",
"\t\t +- org.apache.cayenne.plugins:maven-cayenne-plugin:3.0.2",
"\t\t +- com.example:foo:0.2.0"
)
)
lines.count(_.startsWith("\t\t +-")) should be >= 2
}
property("unresolved warning includes root caller for direct missing dependency") {
val dependencies =
Vector(
"io.github.sbt.nonexistent" % "issue-5168-missing-artifact-zzzzzz" % "0.0.1" % "compile"
)
val unresolvedModule = module(
lmEngine,
stubModule.withRevision("0.3.0"),
dependencies,
Some("2.12.4")
)
val lines = unresolvedWarningLines(unresolvedModule)
assertContainsAll(
lines,
Seq(
"\n\tNote: Unresolved dependencies path:",
"\t\tio.github.sbt.nonexistent:issue-5168-missing-artifact-zzzzzz:0.0.1",
"\t\t +- com.example:foo:0.3.0"
)
)
}
property("resolve sbt jars") {
val dependencies =
Vector("org.scala-sbt" % "sbt" % "1.1.0" % "provided")

View File

@ -0,0 +1,53 @@
import java.io.File
import scala.collection.mutable.ArrayBuffer
import sbt.io.IO
ThisBuild / scalaVersion := "2.12.8"
ThisBuild / organization := "com.example"
ThisBuild / resolvers +=
MavenRepository("local-test-repo", (file("repo") / "").toURI.toASCIIString)
lazy val checkLog = taskKey[Unit]("")
libraryDependencies += "com.example.repo" % "a" % "1.0"
def findUpdateLogs(root: File): Vector[File] =
if !root.exists then Vector.empty
else
val stack = ArrayBuffer(root)
val logs = ArrayBuffer.empty[File]
while stack.nonEmpty do
val current = stack.remove(stack.length - 1)
Option(current.listFiles).getOrElse(Array.empty[File]).foreach: child =>
if child.isDirectory then stack += child
else if child.isFile && child.getName == "out" then
val normalized = child.getPath.replace('\\', '/')
if normalized.contains("/streams/_global/update/_global/streams/out") then logs += child
logs.toVector
checkLog := {
val logs = findUpdateLogs(baseDirectory.value / "target" / "out" / "jvm")
if logs.isEmpty then
sys.error(s"Could not find update stream log under ${baseDirectory.value / "target" / "out" / "jvm"}")
val contentByLog = logs.map(log => log -> IO.read(log))
val combinedContent = contentByLog.map(_._2).mkString("\n")
def assertContains(needle: String): Unit = {
val found = contentByLog.exists(_._2.contains(needle))
assert(
found,
s"""Missing '$needle' in update stream logs:
|${contentByLog.map((log, _) => s" - $log").mkString("\n")}
|
|Collected content:
|$combinedContent
|""".stripMargin
)
}
assertContains("Note: Unresolved dependencies path:")
assertContains("com.example.repo:missing:1.0")
assertContains("com.example.repo:a:1.0")
assertContains("com.example:")
}

View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example.repo</groupId>
<artifactId>a</artifactId>
<version>1.0</version>
<dependencies>
<dependency>
<groupId>com.example.repo</groupId>
<artifactId>missing</artifactId>
<version>1.0</version>
</dependency>
</dependencies>
</project>

View File

@ -0,0 +1,2 @@
-> update
> checkLog