From 67c365997695d6f4c7d45be0b338671c2a71749f Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Fri, 23 Oct 2026 13:59:36 +0200 Subject: [PATCH 1/5] fix(manifest): report the sbt jars that could not be fetched Coursier silently skips a default jar it cannot find unless the POM declares jar packaging, and sbt 0.13's update report omits the artifacts Ivy failed to download, so both looked like POM-only modules. The plugin now tells them apart by the cached POM's packaging and by Ivy's own resolution report, reports them under their full coordinate and marks them unfetched. --- CHANGELOG.md | 1 + .../scripts/socket-facts.plugin.scala | 232 +++++++++++++++--- .../manifest/scripts/test/run-compat.sh | 15 +- .../scripts/test/sbt-compat/.gitignore | 5 + .../sbt-compat/artifact-failure/build.sbt | 20 ++ .../scripts/test/sbt-compat/failure/build.sbt | 12 +- .../test/sbt-compat/smoke-test-failure.sh | 164 ++++++++++++- 7 files changed, 408 insertions(+), 41 deletions(-) create mode 100644 src/commands/manifest/scripts/test/sbt-compat/artifact-failure/build.sbt diff --git a/CHANGELOG.md b/CHANGELOG.md index 72d39c19b..c732fdf94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - A Gradle dependency whose variant is ambiguous in every configuration of its project now fails the run instead of quietly dropping out of the scan. - Gradle dependencies that fail dependency verification, or whose jars cannot be fetched, are reported instead of passed on as healthy. - Failed Gradle project dependencies are reported instead of silently dropping everything they pull in, and failures name the version that actually failed. +- sbt dependencies whose jars coursier skips or Ivy fails to download are reported instead of passed off as POM-only modules, and POM-only, war and aar modules keep their real shape. ## [1.10.0](https://github.com/SocketDev/socket-cli/releases/tag/v1.10.0) - 2026-10-22 diff --git a/src/commands/manifest/scripts/socket-facts.plugin.scala b/src/commands/manifest/scripts/socket-facts.plugin.scala index 2b455276d..e4cbb003e 100644 --- a/src/commands/manifest/scripts/socket-facts.plugin.scala +++ b/src/commands/manifest/scripts/socket-facts.plugin.scala @@ -63,10 +63,26 @@ object SocketFactsPlugin extends AutoPlugin { // Real artifact ext per build module, so an ext-less inter-project dep gets its true coordinate. val moduleExts = buildModuleExts(allRefs, extracted) + // Every project: `update` also runs each upstream project's update, which would fail strictly. + lazy val lenientState = extracted.append(allRefs.map(r => updateConfiguration.in(r) ~= (c => withMissingOk(c))), st) + val pomsByCache = mutable.HashMap.empty[Option[File], PomPackaging] + allRefs.foreach { ref => if (!isExcludedRef(ref)) { - runUpdateResilient(updateTaskName, ref, extracted, st, matcher, failures, unscannable).foreach { case (report, unresolved) => - foldReport(report, unresolved, ref, extracted, matcher, scannedConfigs, withFiles, populateScope, moduleExts).foreach { + // Built-in lm-coursier (sbt 1.3+) and the legacy sbt-coursier plugin name their caches differently. + val legacyCoursierCache = extracted.getOpt(SettingKey[File]("coursierCache").in(ref)) + val csrCache = extracted.getOpt(SettingKey[File]("csrCacheDirectory").in(ref)).orElse(legacyCoursierCache) + val poms = pomsByCache.getOrElseUpdate(csrCache, new PomPackaging(csrCache)) + // sbt 0.13 has no `useCoursier`: it resolves with Ivy unless the legacy plugin replaces it. + val ivy = extracted.getOpt(SettingKey[Boolean]("useCoursier").in(ref)) match { + case Some(useCoursier) => !useCoursier + case None => legacyCoursierCache.isEmpty + } + val resolver = new Resolver(ivy, poms) + runUpdateResilient(updateTaskName, ref, extracted, st, lenientState, matcher, failures, unscannable, resolver) + .foreach { case (report, unresolved, ivyUnfetched, updateFailed) => + foldReport(report, unresolved, ref, extracted, matcher, scannedConfigs, withFiles, populateScope, moduleExts, + resolver, ivyUnfetched, updateFailed, failures).foreach { case (rootKey, tree) => perSub(rootKey) = tree } } @@ -135,6 +151,7 @@ object SocketFactsPlugin extends AutoPlugin { node.project) node.children.foreach(ch => rec("edge", rootId, coordId, ch)) node.targets.foreach(p => rec("file", rootId, coordId, p)) + if (node.unfetched) rec("unfetched", rootId, coordId) } } @@ -194,17 +211,19 @@ object SocketFactsPlugin extends AutoPlugin { }.toMap } - // When a failure names modules, a lenient (missingOk) retry keeps everything else and, where the - // resolver still places a failed module, its configurations. Failed modules are reported, not emitted. + // A failure naming no module is an artifact that could not be fetched, which only the lenient + // report's missing artifacts reveal; with neither, the project is unscannable. private def runUpdateResilient( taskName: String, ref: ProjectRef, extracted: Extracted, state: State, + lenient: => State, matcher: String => Boolean, failures: mutable.LinkedHashSet[Failure], - unscannable: mutable.LinkedHashSet[Unscannable] - ): Option[(UpdateReport, Set[String])] = { + unscannable: mutable.LinkedHashSet[Unscannable], + resolver: Resolver + ): Option[(UpdateReport, Set[String], IvyUnfetched, Boolean)] = { val key = TaskKey[UpdateReport](taskName) def runOn(st: State): Either[(Seq[ModuleID], String), UpdateReport] = @@ -215,14 +234,18 @@ object SocketFactsPlugin extends AutoPlugin { } runOn(state) match { - case Right(rep) => Some((rep, Set.empty[String])) - case Left((failed, detail)) if failed.isEmpty => - unscannable += Unscannable(detail, ref.project) - None + case Right(rep) => Some((rep, Set.empty[String], Map.empty: IvyUnfetched, false)) case Left((failed, detail)) => - val lenient = extracted.append(Seq(updateConfiguration.in(ref) ~= (c => withMissingOk(c))), state) - runOn(lenient) match { - case Right(rep) => + val lenientRun = runOn(lenient).right.map { rep => + (rep, if (resolver.ivy) ivyUnfetchedOf(rep, extracted.get(target.in(ref))) else Map.empty: IvyUnfetched) + } + lenientRun match { + case Right((rep, ivyUnfetched)) + if failed.isEmpty && ivyUnfetched.isEmpty && + !rep.configurations.exists(_.modules.exists(m => !m.evicted && resolver.missingOf(m).nonEmpty)) => + unscannable += Unscannable(detail, ref.project) + None + case Right((rep, ivyUnfetched)) => val confsByGav = mutable.HashMap.empty[String, mutable.LinkedHashSet[String]] rep.configurations.foreach { cr => cr.modules.foreach { r => @@ -236,7 +259,7 @@ object SocketFactsPlugin extends AutoPlugin { case Some(confs) => confs.filter(matcher).foreach(c => failures += Failure(coordOf(m), detailFor(m, detail), c, ref.project)) } } - Some((rep, failed.map(gavKey).toSet)) + Some((rep, failed.map(gavKey).toSet, ivyUnfetched, failed.isEmpty)) case Left(_) => // Even a lenient update failed: the whole project is unresolved, but the modules sbt // named are still reported. @@ -247,6 +270,77 @@ object SocketFactsPlugin extends AutoPlugin { } } + // config -> "org:name:rev" -> artifacts Ivy failed to download. + private type IvyUnfetched = Map[String, Map[String, Seq[IvyArtifact]]] + + private final case class IvyArtifact(ext: String, classifier: String, detail: String) + + // sbt 0.13's UpdateReport omits artifacts Ivy failed to download; Ivy's own report per configuration, + // `/reports/--.xml`, keeps them. + private def ivyUnfetchedOf(rep: UpdateReport, projectTarget: File): IvyUnfetched = { + val descriptor = Option(rep.cachedDescriptor).filter(_.isFile) + val bound = projectTarget.getCanonicalFile.toPath + def reportsDirs(f: File): Stream[File] = + Option(f.getParentFile).filter(_.getCanonicalFile.toPath.startsWith(bound)).toStream + .flatMap(p => new File(p, "reports") #:: reportsDirs(p)) + (for { + d <- descriptor.toSeq + id <- ivyResolveId(d).toSeq + cr <- rep.configurations + conf = confName(cr) + report <- reportsDirs(d).map(new File(_, id + "-" + conf + ".xml")).find(_.isFile).toSeq + arts = ivyFailedArtifacts(report) + if arts.nonEmpty + } yield conf -> arts).toMap + } + + private def parseXml(f: File): org.w3c.dom.Document = { + val dbf = javax.xml.parsers.DocumentBuilderFactory.newInstance() + dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true) + dbf.newDocumentBuilder().parse(f) + } + + // Ivy's ResolveOptions.getDefaultResolveId: `-` of the resolved descriptor. + private def ivyResolveId(descriptor: File): Option[String] = + try { + val info = parseXml(descriptor).getElementsByTagName("info") + if (info.getLength == 0) None + else { + val e = info.item(0).asInstanceOf[org.w3c.dom.Element] + Some(e.getAttribute("organisation") + "-" + e.getAttribute("module")) + } + } catch { case _: Exception => None } + + private def ivyFailedArtifacts(report: File): Map[String, Seq[IvyArtifact]] = + try { + val modules = parseXml(report).getElementsByTagName("module") + val out = for { + mi <- 0 until modules.getLength + module = modules.item(mi).asInstanceOf[org.w3c.dom.Element] + revisions = module.getElementsByTagName("revision") + ri <- 0 until revisions.getLength + revision = revisions.item(ri).asInstanceOf[org.w3c.dom.Element] + if revision.getAttribute("evicted").isEmpty + artifacts = revision.getElementsByTagName("artifact") + ai <- 0 until artifacts.getLength + a = artifacts.item(ai).asInstanceOf[org.w3c.dom.Element] + if a.getAttribute("status") == "failed" + } yield { + val (org, name, rev) = (module.getAttribute("organisation"), module.getAttribute("name"), revision.getAttribute("name")) + val classifier = a.getAttribute("extra-classifier") + val file = a.getAttribute("name") + (if (classifier.isEmpty) "" else "-" + classifier) + "." + a.getAttribute("ext") + // An absent artifact is "missing artifact" (empty under sbt 0.13's Ivy); one the resolver chain + // failed itself has "null"; anything else is a transfer failure's message. + val details = a.getAttribute("details") + val cause = + if (details.isEmpty || details == "missing artifact") "not found" + else if (details == "null") "download failed" + else "download failed (" + details + ")" + (org + ":" + name + ":" + rev) -> IvyArtifact(a.getAttribute("ext"), classifier, cause + ": " + org + "#" + name + ";" + rev + "!" + file) + } + out.groupBy(_._1).map { case (gav, pairs) => gav -> pairs.map(_._2).distinct } + } catch { case _: Exception => Map.empty } + // UpdateConfiguration has `withMissingOk` on sbt 1.x; on 0.13 its only Boolean field is missingOk. private def withMissingOk[T](c: T): T = { val cls = c.getClass @@ -307,7 +401,13 @@ object SocketFactsPlugin extends AutoPlugin { scannedConfigs: mutable.LinkedHashSet[String], withFiles: Boolean, populateScope: Option[Set[String]], - moduleExts: Map[String, String] + moduleExts: Map[String, String], + resolver: Resolver, + ivyUnfetched: IvyUnfetched, + // sbt's own update failed on artifacts alone, so one it could not fetch is a failure even + // without files. + updateFailed: Boolean, + failures: mutable.LinkedHashSet[Failure] ): mutable.LinkedHashMap[String, RootTree] = { val perRoot = mutable.LinkedHashMap.empty[String, RootTree] val rootKey = moduleKey(rootIdOf(extracted, ref)) @@ -386,10 +486,18 @@ object SocketFactsPlugin extends AutoPlugin { cr.modules.foreach { m => if (emittable(m)) { val ids = midToIds.getOrElseUpdate(moduleKey(m.module), mutable.LinkedHashSet.empty[String]) - variantsOf(m).foreach { case (coord, fileOpt) => - val node = nodes.getOrElseUpdate(coord.id, new Node(coord)) - ids += coord.id - if (withFiles && inScope(m.module)) fileOpt.foreach(f => node.targets += f.getAbsolutePath) + val ivyMissing = + if (ivyUnfetched.isEmpty) Nil + else ivyUnfetched.getOrElse(cfg, Map.empty[String, Seq[IvyArtifact]]).getOrElse(gavKey(m.module), Nil) + val recordFiles = withFiles && inScope(m.module) + variantsOf(m, resolver, ivyMissing).foreach { v => + val node = nodes.getOrElseUpdate(v.coord.id, new Node(v.coord)) + ids += v.coord.id + if (recordFiles) v.file.foreach(f => node.targets += f.getAbsolutePath) + v.unfetched.foreach { url => + if (recordFiles) node.unfetched = true + if (recordFiles || updateFailed) failures += Failure(coordOf(m.module), url, cfg, ref.project) + } } } } @@ -419,22 +527,85 @@ object SocketFactsPlugin extends AutoPlugin { perRoot } - private def variantsOf(m: ModuleReport): Seq[(Coord, Option[File])] = { + private def variantsOf(m: ModuleReport, resolver: Resolver, ivyMissing: Seq[IvyArtifact]): Seq[Variant] = { val mid = m.module // `mid.revision` is normally set for a resolved module, but normalize null (Coord.id calls // `.nonEmpty`, which NPEs on null) to match the other call sites (gavKey, module records). val ver = if (mid.revision == null) "" else mid.revision - val arts = m.artifacts - if (arts == null || arts.isEmpty) - // No resolved artifact (e.g. a pom-only module): leave it ext-less. - Seq((Coord(mid.organization, mid.name, ver, "", ""), None)) - else - arts.toList.map { - case (art, file) => - (Coord(mid.organization, mid.name, ver, extOf(art), classifierOf(art)), Option(file)) + def coordFor(art: Artifact) = Coord(mid.organization, mid.name, ver, extOf(art), classifierOf(art)) + val fetched = Option(m.artifacts).toList.flatten.map { case (art, file) => Variant(coordFor(art), Option(file), None) } + val missing = resolver.missingOf(m).map { art => + Variant(coordFor(art), None, Some("not found: " + art.url.map(_.toString).getOrElse(coordFor(art).id))) + } ++ ivyMissing.map(a => Variant(Coord(mid.organization, mid.name, ver, a.ext, a.classifier), None, Some(a.detail))) + if (fetched.isEmpty && missing.isEmpty) + // No artifact expected (a pom-only module): leave it ext-less. + Seq(Variant(Coord(mid.organization, mid.name, ver, "", ""), None, None)) + else fetched ++ missing + } + + // Artifacts the resolver expected and did not fetch. Under Ivy they come from its resolution report + // instead (see ivyUnfetchedOf). + private final class Resolver(val ivy: Boolean, poms: PomPackaging) { + def missingOf(m: ModuleReport): List[Artifact] = + if (ivy) Nil else Option(m.missingArtifacts).toList.flatten.filterNot(poms.isUnexpectedDefault) + } + + // Coursier requests the default jar of every Maven module and skips it silently when absent unless + // the POM declares a jar packaging. Only the module's own POM tells whether its artifact is a jar + // (packaging defaults to jar, so a parent's never applies); an unreadable POM counts as one. + private final class PomPackaging(csrCacheDir: Option[File]) { + private val memo = mutable.HashMap.empty[String, Boolean] + + def isUnexpectedDefault(a: Artifact): Boolean = { + val ext = extOf(a) + a.classifier.isEmpty && ext == "jar" && a.url.map(_.toString).exists { url => + url.endsWith("." + ext) && memo.getOrElseUpdate(url, { + val pomUrl = url.dropRight(ext.length) + "pom" + coursierLocalFiles(pomUrl, csrCacheDir).find(_.isFile).flatMap(pomPackaging).exists(p => !JarPackagings(p)) + }) } + } } + // coursier.maven.MavenAttributes.typeExtensions (2.1.24, which adds ejb): the packagings whose + // artifact is a `.jar`. Any other packaging (pom, aar, war, ...) publishes its own artifact. + private val JarPackagings = Set("eclipse-plugin", "maven-plugin", "hk2-jar", "orbit", "scala-jar", "jar", "bundle", + "doc", "src", "test-jar", "ejb-client", "ejb") + + // coursier.paths.CachePath.localFile. A repository with credentials is cached under `user@host`, + // and the update report does not carry the user. + private def coursierLocalFiles(url: String, csrCacheDir: Option[File]): Seq[File] = + if (url.startsWith("file:/")) + try Seq(new File(new java.net.URI(url))) + catch { case _: Exception => Nil } + else { + val i = url.indexOf(':') + val protocol = if (i < 0) "" else url.substring(0, i) + val rest = if (i < 0) "" else url.substring(i + 1).dropWhile(_ == '/') + csrCacheDir.toSeq.flatMap { dir => + val protocolDir = new File(dir, coursierEscape(protocol)) + val host = coursierEscape(rest.takeWhile(_ != '/')) + val path = coursierEscape(rest.dropWhile(_ != '/').drop(1)) + val withUser = Option(protocolDir.listFiles).toSeq.flatten.filter(_.getName.endsWith("%40" + host)) + (new File(protocolDir, host) +: withUser).map(new File(_, path)) + } + } + + private def coursierEscape(s: String): String = + s.flatMap { ch => + if (ch > 128 || " %$&+,:;=?@<>#".indexOf(ch) >= 0) "%" + "%02X".format(ch.toInt) else ch.toString + } + + // Maven's default packaging is jar. + private def pomPackaging(pom: File): Option[String] = + try { + val kids = parseXml(pom).getDocumentElement.getChildNodes + val declared = (0 until kids.getLength).map(kids.item).collectFirst { + case e: org.w3c.dom.Element if e.getNodeName.split(':').last == "packaging" => e.getTextContent.trim + } + Some(declared.filter(_.nonEmpty).getOrElse("jar")) + } catch { case _: Exception => None } + // ---- config selection --------------------------------------------------- // With no includes the default is ALL configurations (captures build/tooling deps). @@ -618,8 +789,13 @@ object SocketFactsPlugin extends AutoPlugin { var project = "" // External artifact's resolved jar(s); --with-files only. val targets = mutable.TreeSet.empty[String] + // An expected artifact was not fetched; --with-files only. + var unfetched = false } + // `unfetched`: why the expected artifact could not be fetched. + private final case class Variant(coord: Coord, file: Option[File], unfetched: Option[String]) + private final class RootTree( val projectKey: String, val config: String, diff --git a/src/commands/manifest/scripts/test/run-compat.sh b/src/commands/manifest/scripts/test/run-compat.sh index c28f2b23e..fb7b668ff 100755 --- a/src/commands/manifest/scripts/test/run-compat.sh +++ b/src/commands/manifest/scripts/test/run-compat.sh @@ -29,7 +29,8 @@ GRADLE_MATRIX=("1.12 8" "2.14.1 8" "3.3 8" "6.2.2 8" "8.10.2 17" "9.2.1 21") # 3.2.5 is the oldest, 4.x the newest. Resolution goes through Maven's own # ProjectDependenciesResolver, so both ends are load-bearing, not decorative. MAVEN_MATRIX=("3.2.5 8" "3.6.3 11" "3.8.8 17" "3.9.9 17" "4.0.0-rc-6 21") -SBT_MATRIX=("0.13.18 8 2.10.7" "1.4.9 11 2.12.20" "1.6.2 17 2.12.20" "1.9.9 17 2.12.20") +# The `ivy` row runs sbt 1.x with `useCoursier := false`, the other resolver path; failure suite only. +SBT_MATRIX=("0.13.18 8 2.10.7" "1.4.9 11 2.12.20" "1.6.2 17 2.12.20" "1.9.9 17 2.12.20" "1.9.9 17 2.12.20 ivy") # Select a JDK for the given Java major: use $JDK if set, else current java. use_jdk() { @@ -103,12 +104,14 @@ run_sbt() { for row in "${SBT_MATRIX[@]}"; do # shellcheck disable=SC2086 set -- $row - local ver="$1" java="$2" scala="$3" - echo "== sbt $ver / scala $scala (wants JDK $java) ==" + local ver="$1" java="$2" scala="$3" resolver="${4:-}" + echo "== sbt $ver / scala $scala${resolver:+ / $resolver} (wants JDK $java) ==" use_jdk "$java" - bash "$HERE/sbt-compat/smoke-test.sh" "$ver" "$scala" - bash "$HERE/sbt-compat/smoke-test-workspaces.sh" "$ver" "$scala" - bash "$HERE/sbt-compat/smoke-test-failure.sh" "$ver" "$scala" + if [ -z "$resolver" ]; then + bash "$HERE/sbt-compat/smoke-test.sh" "$ver" "$scala" + bash "$HERE/sbt-compat/smoke-test-workspaces.sh" "$ver" "$scala" + fi + bash "$HERE/sbt-compat/smoke-test-failure.sh" "$ver" "$scala" ${resolver:+"$resolver"} done } diff --git a/src/commands/manifest/scripts/test/sbt-compat/.gitignore b/src/commands/manifest/scripts/test/sbt-compat/.gitignore index 77a47cca2..90cf81ffc 100644 --- a/src/commands/manifest/scripts/test/sbt-compat/.gitignore +++ b/src/commands/manifest/scripts/test/sbt-compat/.gitignore @@ -12,3 +12,8 @@ failure/target/ failure/project/ failure/scala-version.sbt failure/records.tsv +artifact-failure/target/ +artifact-failure/*/target/ +artifact-failure/project/ +artifact-failure/scala-version.sbt +artifact-failure/records.tsv diff --git a/src/commands/manifest/scripts/test/sbt-compat/artifact-failure/build.sbt b/src/commands/manifest/scripts/test/sbt-compat/artifact-failure/build.sbt new file mode 100644 index 000000000..e7d024e3f --- /dev/null +++ b/src/commands/manifest/scripts/test/sbt-compat/artifact-failure/build.sbt @@ -0,0 +1,20 @@ +// Artifact-failure fixture for ../../../socket-facts.plugin.scala: every module resolves, but ok has no +// tests-classifier jar, so the only failure is an artifact that names no module. app depends on core, +// whose update sbt also runs for app. Reuses ../failure/localrepo, generated by ../smoke-test-failure.sh. +organization in ThisBuild := "demo" +version in ThisBuild := "0.1.0" + +lazy val stubs = Seq( + resolvers := Seq( + "Socket Stubs" at s"file://${(baseDirectory in ThisBuild).value.getAbsolutePath}/../failure/localrepo" + ) ++ resolvers.value +) + +lazy val core = (project in file("core")) + .settings(stubs) + .settings( + libraryDependencies += "demo.fail" % "ok" % "1.0", + libraryDependencies += "demo.fail" % "ok" % "1.0" classifier "tests" + ) + +lazy val app = (project in file("app")).settings(stubs).dependsOn(core) diff --git a/src/commands/manifest/scripts/test/sbt-compat/failure/build.sbt b/src/commands/manifest/scripts/test/sbt-compat/failure/build.sbt index a9b6ac99c..6cbac47a1 100644 --- a/src/commands/manifest/scripts/test/sbt-compat/failure/build.sbt +++ b/src/commands/manifest/scripts/test/sbt-compat/failure/build.sbt @@ -1,6 +1,8 @@ // Failure fixture for ../../../socket-facts.plugin.scala: ok resolves; lib and testlib name a parent POM -// that does not exist; ghost does not exist at all. Generated into `localrepo` by -// ../smoke-test-failure.sh. Uses the `in ThisBuild` form so it parses on sbt 0.13 and 1.x. +// that does not exist; ghost does not exist at all; nojar has a POM but no jar; pomonly is +// `pom`; pkwar is a war with no jar; ok has no tests-classifier jar. Generated +// into `localrepo` by ../smoke-test-failure.sh. Uses the `in ThisBuild` form so it parses on sbt 0.13 +// and 1.x. organization in ThisBuild := "demo" version in ThisBuild := "0.1.0" @@ -15,5 +17,9 @@ lazy val root = (project in file(".")) libraryDependencies += "demo.fail" % "ok" % "1.0", libraryDependencies += "demo.fail" % "lib" % "1.0", libraryDependencies += "demo.fail" % "testlib" % "1.0" % Test, - libraryDependencies += "demo.fail" % "ghost" % "1.0" + libraryDependencies += "demo.fail" % "ghost" % "1.0", + libraryDependencies += "demo.fail" % "nojar" % "1.0", + libraryDependencies += "demo.fail" % "pomonly" % "1.0", + libraryDependencies += "demo.fail" % "pkwar" % "1.0", + libraryDependencies += "demo.fail" % "ok" % "1.0" % Test classifier "tests" ) diff --git a/src/commands/manifest/scripts/test/sbt-compat/smoke-test-failure.sh b/src/commands/manifest/scripts/test/sbt-compat/smoke-test-failure.sh index b0110cd11..6a6fa2b7f 100755 --- a/src/commands/manifest/scripts/test/sbt-compat/smoke-test-failure.sh +++ b/src/commands/manifest/scripts/test/sbt-compat/smoke-test-failure.sh @@ -5,11 +5,22 @@ # the declared dependencies whose parent POM is missing (lib, testlib), coursier (sbt 1.3+) names the # missing parent POM itself; both name ghost. Each failure's detail is its own module's message. # -# Usage: smoke-test-failure.sh +# A second run with files: a jar that cannot be fetched (nojar; lib/testlib's under coursier; ok's +# tests classifier, test-scoped) must be reported and marked `unfetched` under its full coordinate, +# in the configurations that requested it, while pomonly stays an artifactless module and pkwar keeps +# its war. Coursier skips a missing jar silently and lists one for every Maven module whatever its +# packaging; Ivy's own resolution report names what it failed to download. +# +# Then ../artifact-failure, whose only failure is ok's missing tests jar, without and with files: an +# artifact failure names no module, yet sbt's update fails on it, so it must be reported in both runs +# and must not make core or its dependent app unscannable. +# +# Usage: smoke-test-failure.sh [coursier|ivy] (ivy: sbt 1.x only) set -euo pipefail HERE="$(cd "$(dirname "$0")" && pwd)" SBT_VERSION="${1:?usage: smoke-test-failure.sh }" SCALA_VERSION="${2:?usage: smoke-test-failure.sh }" +RESOLVER="${3:-coursier}" PLUGIN="$HERE/../../socket-facts.plugin.scala" PROJECT="$HERE/failure" RECORDS="$PROJECT/records.tsv" @@ -34,6 +45,34 @@ for lib in lib testlib; do POM done +mkdir -p "$REPO/demo/fail/nojar/1.0" "$REPO/demo/fail/pomonly/1.0" "$REPO/demo/fail/pkwar/1.0" +cat > "$REPO/demo/fail/pkwar/1.0/pkwar-1.0.pom" <<'POM' + + 4.0.0 + demo.fail + pkwar + 1.0 + war + +POM +cp "$REPO/demo/fail/ok/1.0/ok-1.0.jar" "$REPO/demo/fail/pkwar/1.0/pkwar-1.0.war" +cat > "$REPO/demo/fail/nojar/1.0/nojar-1.0.pom" <<'POM' + + 4.0.0 + demo.fail + nojar + 1.0 + +POM +cat > "$REPO/demo/fail/pomonly/1.0/pomonly-1.0.pom" <<'POM' + + 4.0.0 + demo.fail + pomonly + 1.0 + pom + +POM rm -rf "$IVY/cache/demo.fail" GB="$(mktemp -d)/global-base" @@ -43,12 +82,20 @@ cp "$PLUGIN" "$GB/plugins/SocketFactsPlugin.scala" mkdir -p "$PROJECT/project" echo "sbt.version=$SBT_VERSION" > "$PROJECT/project/build.properties" echo "scalaVersion in ThisBuild := \"$SCALA_VERSION\"" > "$PROJECT/scala-version.sbt" +if [ "$RESOLVER" = ivy ]; then + echo 'useCoursier in ThisBuild := false' >> "$PROJECT/scala-version.sbt" +fi rm -rf "$RECORDS" "$PROJECT/target" "$PROJECT/project/target" +run() { + local dir="$1" + shift + ( cd "$dir" && sbt -Dsbt.global.base="$GB" -Dsbt.server.autostart=false \ + -Dsbt.ivy.home="$IVY" "$@" --batch socketFacts ) +} + echo "+ sbt $SBT_VERSION (scala $SCALA_VERSION) (failure)" -( cd "$PROJECT" && sbt -Dsbt.global.base="$GB" -Dsbt.server.autostart=false \ - -Dsbt.ivy.home="$IVY" \ - -Dsocket.recordsFile="$RECORDS" --batch socketFacts ) +run "$PROJECT" -Dsocket.recordsFile="$RECORDS" python3 - "$RECORDS" <<'PY' import sys @@ -84,3 +131,112 @@ if errors: sys.exit(1) print(f"PASS (failure): reported {sorted(failed)}; ok still emitted") PY + +rm -rf "$RECORDS" "$PROJECT/target" "$PROJECT/project/target" +echo "+ sbt $SBT_VERSION (scala $SCALA_VERSION) (failure, with files)" +run "$PROJECT" -Dsocket.withFiles=true -Dsocket.recordsFile="$RECORDS" + +python3 - "$RECORDS" <<'PY' +import sys +rows = [l.rstrip('\n').split('\t') for l in open(sys.argv[1]) if l.strip()] +nodes = {r[2]: r for r in rows if r[0] == 'node'} +unfetched = {r[2] for r in rows if r[0] == 'unfetched'} +failed = {r[1] for r in rows if r[0] == 'failure'} +config_of = {r[1]: r[3] for r in rows if r[0] == 'root'} +unfetched_configs = {(r[2], config_of[r[1]]) for r in rows if r[0] == 'unfetched'} +files = {r[2] for r in rows if r[0] == 'file'} +errors = [] + +if 'demo.fail:pomonly:1.0' not in nodes: + errors.append(f"pomonly is pom-packaged, so it must be emitted without an artifact; nodes={sorted(nodes)}") +if any('pomonly' in u for u in unfetched) or 'demo.fail:pomonly:1.0' in failed: + errors.append(f"pomonly has no jar to fetch; unfetched={sorted(unfetched)} failures={sorted(failed)}") +for name in ('nojar', 'ok'): + if f'demo.fail:{name}:1.0' not in failed: + errors.append(f"{name}'s jar cannot be fetched, so it must be reported; failures={sorted(failed)}") +if 'demo.fail:nojar:jar:1.0' not in unfetched: + errors.append(f"nojar must be emitted as an unfetched jar; unfetched={sorted(unfetched)}") +if 'demo.fail:ok:jar:1.0' not in files: + errors.append("ok's own jar resolved, so its missing tests jar must not cost it its file") +tests_configs = {c for (u, c) in unfetched_configs if u == 'demo.fail:ok:jar:tests:1.0'} +if not tests_configs or any('test' not in c.lower() for c in tests_configs): + errors.append(f"ok's tests jar is requested only in Test, so only test configs may miss it: {sorted(tests_configs)}") +for name in ('nojar', 'lib', 'testlib'): + if f'demo.fail:{name}:1.0' in nodes: + errors.append(f"{name} has a jar-packaged POM, so it must not look artifactless") + if f'demo.fail:{name}:jar:1.0' in nodes and f'demo.fail:{name}:jar:1.0' not in unfetched: + errors.append(f"{name} is emitted without its jar, so it must be marked unfetched; unfetched={sorted(unfetched)}") +if 'demo.fail:pkwar:war:1.0' not in files: + errors.append(f"pkwar's war resolved, so it must keep its file; nodes={sorted(n for n in nodes if 'pkwar' in n)}") +if any('pkwar' in f for f in failed): + errors.append(f"pkwar's packaging is war, so a missing jar is expected; failures={sorted(failed)}") +details = [r[2] for r in rows if r[0] == 'failure' and r[1] == 'demo.fail:nojar:1.0'] +if len(set(details)) != 1 or 'not found' not in details[0]: + errors.append(f"nojar's jar is absent, so it must be reported once as not found: {sorted(set(details))}") +stray = unfetched - {f'demo.fail:{n}:jar:1.0' for n in ('nojar', 'lib', 'testlib')} - {'demo.fail:ok:jar:tests:1.0'} +if stray: + errors.append(f"only the jars that are missing may be unfetched: {sorted(stray)}") + +if errors: + print("FAIL:") + for e in errors: + print(" -", e) + sys.exit(1) +print(f"PASS (failure, with files): unfetched {sorted(unfetched)}; pomonly artifactless") +PY + +ARTIFACT="$HERE/artifact-failure" +mkdir -p "$ARTIFACT/project" +echo "sbt.version=$SBT_VERSION" > "$ARTIFACT/project/build.properties" +cp "$PROJECT/scala-version.sbt" "$ARTIFACT/scala-version.sbt" +rm -rf "$ARTIFACT/records.tsv" "$ARTIFACT/target" "$ARTIFACT"/*/target "$ARTIFACT/project/target" +echo "+ sbt $SBT_VERSION (scala $SCALA_VERSION) (artifact failure)" +run "$ARTIFACT" -Dsocket.recordsFile="$ARTIFACT/records.tsv" + +python3 - "$ARTIFACT/records.tsv" <<'PY' +import sys +rows = [l.rstrip('\n').split('\t') for l in open(sys.argv[1]) if l.strip()] +unscannable = [r[1:] for r in rows if r[0] == 'unscannable'] +failed = {r[1] for r in rows if r[0] == 'failure'} +errors = [] +if unscannable: + errors.append(f"a missing artifact names no module, but must not make a project unscannable: {unscannable}") +if failed != {'demo.fail:ok:1.0'}: + errors.append(f"sbt's update fails on ok's missing tests jar, so it must be reported without files too; failures={sorted(failed)}") +if errors: + print("FAIL:") + for e in errors: + print(" -", e) + sys.exit(1) +print("PASS (artifact failure): ok's missing tests jar reported without files") +PY + +rm -rf "$ARTIFACT/records.tsv" +echo "+ sbt $SBT_VERSION (scala $SCALA_VERSION) (artifact failure, with files)" +run "$ARTIFACT" -Dsocket.withFiles=true -Dsocket.recordsFile="$ARTIFACT/records.tsv" + +python3 - "$ARTIFACT/records.tsv" <<'PY' +import sys +rows = [l.rstrip('\n').split('\t') for l in open(sys.argv[1]) if l.strip()] +unscannable = [r[1:] for r in rows if r[0] == 'unscannable'] +project_of = {r[1]: r[2] for r in rows if r[0] == 'root'} +unfetched_by = {} +for r in rows: + if r[0] == 'unfetched': + unfetched_by.setdefault(project_of[r[1]], set()).add(r[2]) +failed = {r[1] for r in rows if r[0] == 'failure'} +errors = [] +if unscannable: + errors.append(f"a missing artifact names no module, but must not make a project unscannable: {unscannable}") +for p in ('core', 'app'): + if unfetched_by.get(p) != {'demo.fail:ok:jar:tests:1.0'}: + errors.append(f"{p} reaches ok's missing tests jar, so only it may be unfetched: {sorted(unfetched_by.get(p, ()))}") +if failed != {'demo.fail:ok:1.0'}: + errors.append(f"only ok's tests jar is missing; failures={sorted(failed)}") +if errors: + print("FAIL:") + for e in errors: + print(" -", e) + sys.exit(1) +print("PASS (artifact failure, with files): ok's tests jar unfetched in core and app; nothing unscannable") +PY From 07e9e81396b4f06b6c366285f51d76f79c061298 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Fri, 23 Oct 2026 18:13:53 +0200 Subject: [PATCH 2/5] fix(manifest): read an sbt POM that declares a DOCTYPE The cached-POM packaging check refused any DOCTYPE, so a pom-packaged module whose POM declared one counted as a jar, and its expected missing jar blocked the run. A DOCTYPE is now allowed with external entities and DTD loading off. A jar coursier lists as missing is no longer reported as not found: the update report gives no cause, and a 401 or a timeout is as likely. Co-Authored-By: Claude Opus 5.5 --- .../scripts/socket-facts.plugin.scala | 11 ++++- .../scripts/test/sbt-compat/failure/build.sbt | 3 +- .../test/sbt-compat/smoke-test-failure.sh | 41 +++++++++++++++---- 3 files changed, 44 insertions(+), 11 deletions(-) diff --git a/src/commands/manifest/scripts/socket-facts.plugin.scala b/src/commands/manifest/scripts/socket-facts.plugin.scala index e4cbb003e..abed42818 100644 --- a/src/commands/manifest/scripts/socket-facts.plugin.scala +++ b/src/commands/manifest/scripts/socket-facts.plugin.scala @@ -295,8 +295,14 @@ object SocketFactsPlugin extends AutoPlugin { } private def parseXml(f: File): org.w3c.dom.Document = { + // A POM may declare a DOCTYPE, so only what it could pull in from outside is refused. val dbf = javax.xml.parsers.DocumentBuilderFactory.newInstance() - dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true) + dbf.setFeature(javax.xml.XMLConstants.FEATURE_SECURE_PROCESSING, true) + dbf.setFeature("http://xml.org/sax/features/external-general-entities", false) + dbf.setFeature("http://xml.org/sax/features/external-parameter-entities", false) + dbf.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false) + dbf.setXIncludeAware(false) + dbf.setExpandEntityReferences(false) dbf.newDocumentBuilder().parse(f) } @@ -535,7 +541,8 @@ object SocketFactsPlugin extends AutoPlugin { def coordFor(art: Artifact) = Coord(mid.organization, mid.name, ver, extOf(art), classifierOf(art)) val fetched = Option(m.artifacts).toList.flatten.map { case (art, file) => Variant(coordFor(art), Option(file), None) } val missing = resolver.missingOf(m).map { art => - Variant(coordFor(art), None, Some("not found: " + art.url.map(_.toString).getOrElse(coordFor(art).id))) + // The update report does not say why: a 401 or a timeout is as likely as an absent file. + Variant(coordFor(art), None, Some("could not be fetched: " + art.url.map(_.toString).getOrElse(coordFor(art).id))) } ++ ivyMissing.map(a => Variant(Coord(mid.organization, mid.name, ver, a.ext, a.classifier), None, Some(a.detail))) if (fetched.isEmpty && missing.isEmpty) // No artifact expected (a pom-only module): leave it ext-less. diff --git a/src/commands/manifest/scripts/test/sbt-compat/failure/build.sbt b/src/commands/manifest/scripts/test/sbt-compat/failure/build.sbt index 6cbac47a1..42fc9eb4d 100644 --- a/src/commands/manifest/scripts/test/sbt-compat/failure/build.sbt +++ b/src/commands/manifest/scripts/test/sbt-compat/failure/build.sbt @@ -1,6 +1,6 @@ // Failure fixture for ../../../socket-facts.plugin.scala: ok resolves; lib and testlib name a parent POM // that does not exist; ghost does not exist at all; nojar has a POM but no jar; pomonly is -// `pom`; pkwar is a war with no jar; ok has no tests-classifier jar. Generated +// `pom`, and so is doctype, whose POM declares a DOCTYPE; pkwar is a war with no jar; ok has no tests-classifier jar. Generated // into `localrepo` by ../smoke-test-failure.sh. Uses the `in ThisBuild` form so it parses on sbt 0.13 // and 1.x. organization in ThisBuild := "demo" @@ -20,6 +20,7 @@ lazy val root = (project in file(".")) libraryDependencies += "demo.fail" % "ghost" % "1.0", libraryDependencies += "demo.fail" % "nojar" % "1.0", libraryDependencies += "demo.fail" % "pomonly" % "1.0", + libraryDependencies += "demo.fail" % "doctype" % "1.0", libraryDependencies += "demo.fail" % "pkwar" % "1.0", libraryDependencies += "demo.fail" % "ok" % "1.0" % Test classifier "tests" ) diff --git a/src/commands/manifest/scripts/test/sbt-compat/smoke-test-failure.sh b/src/commands/manifest/scripts/test/sbt-compat/smoke-test-failure.sh index 6a6fa2b7f..3b01135c1 100755 --- a/src/commands/manifest/scripts/test/sbt-compat/smoke-test-failure.sh +++ b/src/commands/manifest/scripts/test/sbt-compat/smoke-test-failure.sh @@ -7,8 +7,9 @@ # # A second run with files: a jar that cannot be fetched (nojar; lib/testlib's under coursier; ok's # tests classifier, test-scoped) must be reported and marked `unfetched` under its full coordinate, -# in the configurations that requested it, while pomonly stays an artifactless module and pkwar keeps -# its war. Coursier skips a missing jar silently and lists one for every Maven module whatever its +# in the configurations that requested it, while pomonly (and under coursier doctype, whose POM +# declares a DOCTYPE) stays an artifactless module and pkwar keeps its war. Coursier's update report gives no cause for a +# missing artifact, so none is claimed for it. Coursier skips a missing jar silently and lists one for every Maven module whatever its # packaging; Ivy's own resolution report names what it failed to download. # # Then ../artifact-failure, whose only failure is ok's missing tests jar, without and with files: an @@ -45,7 +46,7 @@ for lib in lib testlib; do POM done -mkdir -p "$REPO/demo/fail/nojar/1.0" "$REPO/demo/fail/pomonly/1.0" "$REPO/demo/fail/pkwar/1.0" +mkdir -p "$REPO/demo/fail/nojar/1.0" "$REPO/demo/fail/pomonly/1.0" "$REPO/demo/fail/pkwar/1.0" "$REPO/demo/fail/doctype/1.0" cat > "$REPO/demo/fail/pkwar/1.0/pkwar-1.0.pom" <<'POM' 4.0.0 @@ -73,6 +74,17 @@ cat > "$REPO/demo/fail/pomonly/1.0/pomonly-1.0.pom" <<'POM' pom POM +cat > "$REPO/demo/fail/doctype/1.0/doctype-1.0.pom" <<'POM' + + + + 4.0.0 + demo.fail + doctype + 1.0 + pom + +POM rm -rf "$IVY/cache/demo.fail" GB="$(mktemp -d)/global-base" @@ -136,9 +148,14 @@ rm -rf "$RECORDS" "$PROJECT/target" "$PROJECT/project/target" echo "+ sbt $SBT_VERSION (scala $SCALA_VERSION) (failure, with files)" run "$PROJECT" -Dsocket.withFiles=true -Dsocket.recordsFile="$RECORDS" -python3 - "$RECORDS" <<'PY' +IVY_RESOLVER=false +if [ "$RESOLVER" = ivy ] || [ "${SBT_VERSION%%.*}" = 0 ]; then + IVY_RESOLVER=true +fi +python3 - "$RECORDS" "$IVY_RESOLVER" <<'PY' import sys rows = [l.rstrip('\n').split('\t') for l in open(sys.argv[1]) if l.strip()] +ivy = sys.argv[2] == 'true' nodes = {r[2]: r for r in rows if r[0] == 'node'} unfetched = {r[2] for r in rows if r[0] == 'unfetched'} failed = {r[1] for r in rows if r[0] == 'failure'} @@ -149,8 +166,14 @@ errors = [] if 'demo.fail:pomonly:1.0' not in nodes: errors.append(f"pomonly is pom-packaged, so it must be emitted without an artifact; nodes={sorted(nodes)}") -if any('pomonly' in u for u in unfetched) or 'demo.fail:pomonly:1.0' in failed: - errors.append(f"pomonly has no jar to fetch; unfetched={sorted(unfetched)} failures={sorted(failed)}") +# Ivy's POM reader declares a DOCTYPE of its own, so it fails on doctype's; coursier reads it. +if ivy and 'demo.fail:doctype:1.0' not in failed: + errors.append(f"Ivy cannot read doctype's POM, so it must be reported; failures={sorted(failed)}") +for name in ('pomonly',) if ivy else ('pomonly', 'doctype'): + if f'demo.fail:{name}:1.0' not in nodes: + errors.append(f"{name} is pom-packaged, so it must be emitted without an artifact; nodes={sorted(nodes)}") + if any(name in u for u in unfetched) or f'demo.fail:{name}:1.0' in failed: + errors.append(f"{name} has no jar to fetch; unfetched={sorted(unfetched)} failures={sorted(failed)}") for name in ('nojar', 'ok'): if f'demo.fail:{name}:1.0' not in failed: errors.append(f"{name}'s jar cannot be fetched, so it must be reported; failures={sorted(failed)}") @@ -171,8 +194,10 @@ if 'demo.fail:pkwar:war:1.0' not in files: if any('pkwar' in f for f in failed): errors.append(f"pkwar's packaging is war, so a missing jar is expected; failures={sorted(failed)}") details = [r[2] for r in rows if r[0] == 'failure' and r[1] == 'demo.fail:nojar:1.0'] -if len(set(details)) != 1 or 'not found' not in details[0]: - errors.append(f"nojar's jar is absent, so it must be reported once as not found: {sorted(set(details))}") +# Ivy's report names the cause; coursier's update report does not, so none may be claimed. +expected_cause = 'not found' if ivy else 'could not be fetched' +if len(set(details)) != 1 or expected_cause not in details[0]: + errors.append(f"nojar's jar is absent, so it must be reported once as {expected_cause!r}: {sorted(set(details))}") stray = unfetched - {f'demo.fail:{n}:jar:1.0' for n in ('nojar', 'lib', 'testlib')} - {'demo.fail:ok:jar:tests:1.0'} if stray: errors.append(f"only the jars that are missing may be unfetched: {sorted(stray)}") From 0ef75b8d89f34238fa1e8b4f4a15e423f718caa7 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Fri, 23 Oct 2026 18:24:17 +0200 Subject: [PATCH 3/5] test(manifest): treat sbt 1.0-1.2 as resolving with Ivy Co-Authored-By: Claude Opus 5.5 --- .../manifest/scripts/test/sbt-compat/smoke-test-failure.sh | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/commands/manifest/scripts/test/sbt-compat/smoke-test-failure.sh b/src/commands/manifest/scripts/test/sbt-compat/smoke-test-failure.sh index 3b01135c1..ef95ce14a 100755 --- a/src/commands/manifest/scripts/test/sbt-compat/smoke-test-failure.sh +++ b/src/commands/manifest/scripts/test/sbt-compat/smoke-test-failure.sh @@ -148,8 +148,11 @@ rm -rf "$RECORDS" "$PROJECT/target" "$PROJECT/project/target" echo "+ sbt $SBT_VERSION (scala $SCALA_VERSION) (failure, with files)" run "$PROJECT" -Dsocket.withFiles=true -Dsocket.recordsFile="$RECORDS" +# sbt resolves with coursier by default from 1.3. IVY_RESOLVER=false -if [ "$RESOLVER" = ivy ] || [ "${SBT_VERSION%%.*}" = 0 ]; then +SBT_MINOR="${SBT_VERSION#*.}" +SBT_MINOR="${SBT_MINOR%%.*}" +if [ "$RESOLVER" = ivy ] || [ "${SBT_VERSION%%.*}" = 0 ] || { [ "${SBT_VERSION%%.*}" = 1 ] && [ "$SBT_MINOR" -lt 3 ]; }; then IVY_RESOLVER=true fi python3 - "$RECORDS" "$IVY_RESOLVER" <<'PY' From 72aff89eb297529b754870d6be3d70f796b13548 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Fri, 23 Oct 2026 18:31:56 +0200 Subject: [PATCH 4/5] test(manifest): simplify the sbt resolver check in the failure smoke test Co-Authored-By: Claude Opus 5.5 --- .../scripts/test/sbt-compat/smoke-test-failure.sh | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/commands/manifest/scripts/test/sbt-compat/smoke-test-failure.sh b/src/commands/manifest/scripts/test/sbt-compat/smoke-test-failure.sh index ef95ce14a..5cdc2a27c 100755 --- a/src/commands/manifest/scripts/test/sbt-compat/smoke-test-failure.sh +++ b/src/commands/manifest/scripts/test/sbt-compat/smoke-test-failure.sh @@ -150,11 +150,9 @@ run "$PROJECT" -Dsocket.withFiles=true -Dsocket.recordsFile="$RECORDS" # sbt resolves with coursier by default from 1.3. IVY_RESOLVER=false -SBT_MINOR="${SBT_VERSION#*.}" -SBT_MINOR="${SBT_MINOR%%.*}" -if [ "$RESOLVER" = ivy ] || [ "${SBT_VERSION%%.*}" = 0 ] || { [ "${SBT_VERSION%%.*}" = 1 ] && [ "$SBT_MINOR" -lt 3 ]; }; then - IVY_RESOLVER=true -fi +case "$RESOLVER:$SBT_VERSION" in + ivy:*|*:0.*|*:1.[0-2].*) IVY_RESOLVER=true ;; +esac python3 - "$RECORDS" "$IVY_RESOLVER" <<'PY' import sys rows = [l.rstrip('\n').split('\t') for l in open(sys.argv[1]) if l.strip()] @@ -167,8 +165,6 @@ unfetched_configs = {(r[2], config_of[r[1]]) for r in rows if r[0] == 'unfetched files = {r[2] for r in rows if r[0] == 'file'} errors = [] -if 'demo.fail:pomonly:1.0' not in nodes: - errors.append(f"pomonly is pom-packaged, so it must be emitted without an artifact; nodes={sorted(nodes)}") # Ivy's POM reader declares a DOCTYPE of its own, so it fails on doctype's; coursier reads it. if ivy and 'demo.fail:doctype:1.0' not in failed: errors.append(f"Ivy cannot read doctype's POM, so it must be reported; failures={sorted(failed)}") From b50950b56ce9d6dc286720f48ee9fec08afc1649 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Fri, 23 Oct 2026 18:34:42 +0200 Subject: [PATCH 5/5] test(manifest): rewrap the sbt failure fixture comments Co-Authored-By: Claude Opus 5.5 --- .../manifest/scripts/test/sbt-compat/failure/build.sbt | 6 +++--- .../manifest/scripts/test/sbt-compat/smoke-test-failure.sh | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/commands/manifest/scripts/test/sbt-compat/failure/build.sbt b/src/commands/manifest/scripts/test/sbt-compat/failure/build.sbt index 42fc9eb4d..5f4b4aa4e 100644 --- a/src/commands/manifest/scripts/test/sbt-compat/failure/build.sbt +++ b/src/commands/manifest/scripts/test/sbt-compat/failure/build.sbt @@ -1,8 +1,8 @@ // Failure fixture for ../../../socket-facts.plugin.scala: ok resolves; lib and testlib name a parent POM // that does not exist; ghost does not exist at all; nojar has a POM but no jar; pomonly is -// `pom`, and so is doctype, whose POM declares a DOCTYPE; pkwar is a war with no jar; ok has no tests-classifier jar. Generated -// into `localrepo` by ../smoke-test-failure.sh. Uses the `in ThisBuild` form so it parses on sbt 0.13 -// and 1.x. +// `pom`, and so is doctype, whose POM declares a DOCTYPE; pkwar is a war with no +// jar; ok has no tests-classifier jar. Generated into `localrepo` by ../smoke-test-failure.sh. Uses the +// `in ThisBuild` form so it parses on sbt 0.13 and 1.x. organization in ThisBuild := "demo" version in ThisBuild := "0.1.0" diff --git a/src/commands/manifest/scripts/test/sbt-compat/smoke-test-failure.sh b/src/commands/manifest/scripts/test/sbt-compat/smoke-test-failure.sh index 5cdc2a27c..f33706197 100755 --- a/src/commands/manifest/scripts/test/sbt-compat/smoke-test-failure.sh +++ b/src/commands/manifest/scripts/test/sbt-compat/smoke-test-failure.sh @@ -8,9 +8,9 @@ # A second run with files: a jar that cannot be fetched (nojar; lib/testlib's under coursier; ok's # tests classifier, test-scoped) must be reported and marked `unfetched` under its full coordinate, # in the configurations that requested it, while pomonly (and under coursier doctype, whose POM -# declares a DOCTYPE) stays an artifactless module and pkwar keeps its war. Coursier's update report gives no cause for a -# missing artifact, so none is claimed for it. Coursier skips a missing jar silently and lists one for every Maven module whatever its -# packaging; Ivy's own resolution report names what it failed to download. +# declares a DOCTYPE) stays an artifactless module and pkwar keeps its war. Coursier skips a missing +# jar silently and lists one for every Maven module whatever its packaging, without a cause; Ivy's +# own resolution report names what it failed to download, and why. # # Then ../artifact-failure, whose only failure is ok's missing tests jar, without and with files: an # artifact failure names no module, yet sbt's update fails on it, so it must be reported in both runs