mirror of https://github.com/sbt/sbt.git
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:
parent
023d3ba2c5
commit
db41205098
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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:")
|
||||
}
|
||||
Binary file not shown.
|
|
@ -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>
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
-> update
|
||||
> checkLog
|
||||
Loading…
Reference in New Issue