From db4120509803cda0647f52e8ef8ad7b55e113673 Mon Sep 17 00:00:00 2001 From: eugene yokota Date: Sun, 22 Mar 2026 21:53:20 -0400 Subject: [PATCH] [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> --- .../CoursierDependencyResolution.scala | 114 +++++++++++++++++- .../scala/lmcoursier/ResolutionSpec.scala | 61 ++++++++++ .../lm-coursier/unresolved-path/build.sbt | 53 ++++++++ .../repo/com/example/repo/a/1.0/a-1.0.jar | Bin 0 -> 330 bytes .../repo/com/example/repo/a/1.0/a-1.0.pom | 16 +++ .../sbt-test/lm-coursier/unresolved-path/test | 2 + 6 files changed, 242 insertions(+), 4 deletions(-) create mode 100644 sbt-app/src/sbt-test/lm-coursier/unresolved-path/build.sbt create mode 100644 sbt-app/src/sbt-test/lm-coursier/unresolved-path/repo/com/example/repo/a/1.0/a-1.0.jar create mode 100644 sbt-app/src/sbt-test/lm-coursier/unresolved-path/repo/com/example/repo/a/1.0/a-1.0.pom create mode 100644 sbt-app/src/sbt-test/lm-coursier/unresolved-path/test diff --git a/lm-coursier/src/main/scala/lmcoursier/CoursierDependencyResolution.scala b/lm-coursier/src/main/scala/lmcoursier/CoursierDependencyResolution.scala index 13d32316e..e993ab88b 100644 --- a/lm-coursier/src/main/scala/lmcoursier/CoursierDependencyResolution.scala +++ b/lm-coursier/src/main/scala/lmcoursier/CoursierDependencyResolution.scala @@ -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 diff --git a/lm-coursier/src/test/scala/lmcoursier/ResolutionSpec.scala b/lm-coursier/src/test/scala/lmcoursier/ResolutionSpec.scala index b846752f7..e33a33be9 100644 --- a/lm-coursier/src/test/scala/lmcoursier/ResolutionSpec.scala +++ b/lm-coursier/src/test/scala/lmcoursier/ResolutionSpec.scala @@ -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") diff --git a/sbt-app/src/sbt-test/lm-coursier/unresolved-path/build.sbt b/sbt-app/src/sbt-test/lm-coursier/unresolved-path/build.sbt new file mode 100644 index 000000000..c49d4a1d4 --- /dev/null +++ b/sbt-app/src/sbt-test/lm-coursier/unresolved-path/build.sbt @@ -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:") +} diff --git a/sbt-app/src/sbt-test/lm-coursier/unresolved-path/repo/com/example/repo/a/1.0/a-1.0.jar b/sbt-app/src/sbt-test/lm-coursier/unresolved-path/repo/com/example/repo/a/1.0/a-1.0.jar new file mode 100644 index 0000000000000000000000000000000000000000..5d2601d1a7639aaf376f5deaa927b513103ec174 GIT binary patch literal 330 zcmWIWW@Zs#;Nak35RgrdVL$?$3@i-3t|5-Po_=on|4uP5Ff#;rvvYt{FhP|C;M6Pv zQ~}rQ>*(j{<{BKL=j-;__snS@Z(Y5MyxzK6=gyqp9At3C_`%a6JuhD!Pv48Bt5~>Z zyq0_+8KsfYqBix})G1k?iar)Gf^A;1q|Vj?Xs$UB2Y53wi7=o#9Of#J!%+d;B_MC0 iYehB| + + 4.0.0 + com.example.repo + a + 1.0 + + + com.example.repo + missing + 1.0 + + + diff --git a/sbt-app/src/sbt-test/lm-coursier/unresolved-path/test b/sbt-app/src/sbt-test/lm-coursier/unresolved-path/test new file mode 100644 index 000000000..ff12e6482 --- /dev/null +++ b/sbt-app/src/sbt-test/lm-coursier/unresolved-path/test @@ -0,0 +1,2 @@ +-> update +> checkLog