sbtでそれぞれのsub projectで同じライブラリの異なるversionに依存しているのを検知する方法

昨日のこれ

xuwei-k.hatenablog.com

の続きのような、関連するような話です。

昨日のものは、揃えるためにversionを取得する話でしたが、そもそも全部のライブラリに個別にその方法で漏れなく依存書くのは割と面倒というか厳しいので、同じになっているか?をcheckする仕組みが欲しくありませんか?

自分が知る限りOSSで存在しないと思うんですが、実は既にあったら教えてください。 *1

昨日の例をもう一度貼り付けますが

// playframeworkのproject
lazy val mainProject = project
  .dependsOn(lib)
  .enablePlugins(PlayScala)

lazy val lib = project
  .settings(
    libraryDependencies += // ここにplay-json
  )

playframework本体の3.0.8時点で依存するplay-jsonは3.0.5です。よって、揃えたいならlibでも3.0.5を設定するべきです。しかし、3.0.4を使ってしまっていたとしましょう。

これも繰り返しになるというか、昨日の件のモチベーション詳細を今更こっちで説明するのですが、大抵はその程度の違いなら問題は起きないです。3.0.4と3.0.5くらいなら、ソース互換もバイナリ互換もあるので。

しかし、libの src/test/scala に書いたテストは、この場合はplay-json 3.0.4でテストされるわけですね?

しかしlibは単体でデプロイせず、最終的にデプロイされるのはmainProject部分だったとすると、実際に本番環境で動くのはplay-json 3.0.5なのに、3.0.4を使ったテストになってしまうわけです。

すごく厳密に考えるとそれは良くないですね?

また、macroの展開が絡んでくると、testがどうなるか?以外にも実際にmain側の挙動の違いにも発展する可能性があります。 つまり、3.0.4時点のmacro実装がlibのcompile時に展開されたとして、その部分のコードはmainProjectのplay-jsonが3.0.5だからといって、3.0.5のmacro実装で上書きされるわけではありません。

例えばこういうの

github.com

かといって、

(少なくともテスト観点では)「全部のテストをmainProjectにおけばいいじゃん!」

とすると、テストの問題は解決するかもしれませんが、build速度その他の面で明らかなデメリットがあるので、全部のテストを1箇所にまとめるのは、絶対にやりたくありません。

xuwei-k.hatenablog.com

よって、一発でcheckできるsbtのtaskやpluginがあればいいのでは???と思ったので、ひとまず最低限作れた結果を貼っておきます。

これを project/CheckSameDependency.scala に置いて checkSameVersion 実行してください

import sbt.*
import sbt.Keys.*

object CheckSameDependency extends AutoPlugin {
  override def trigger = allRequirements

  object autoImport {
    val externalDependencyModuleIds = taskKey[(String, Seq[ModuleID])]("")
    val checkSameVersion = taskKey[Unit]("")
  }
  import autoImport.*

  private final case class Module(groupId: String, artifactId: String)
  private final case class Ver(projectId: String, revision: String)

  override def buildSettings: Seq[Def.Setting[?]] = Seq(
    checkSameVersion := {
      val result: Seq[(String, Seq[ModuleID])] = Def
        .taskDyn {
          val extracted = Project.extract(state.value)
          val currentBuildUri = extracted.currentRef.build
          extracted.structure.units
            .apply(currentBuildUri)
            .defined
            .values
            .toList
            .map { p =>
              LocalProject(p.id) / Compile / externalDependencyModuleIds
            }
            .join
        }
        .value

      val allModules: Seq[Module] = result.flatMap(_._2).map(a => Module(a.organization, a.name))

      val map: Map[Module, Seq[Ver]] = allModules.map {
        m =>
          val xs = result.flatMap { case (proj, modules) =>
            modules.find(x => x.organization == m.groupId && x.name == m.artifactId).map(x => Ver(proj, x.revision))
          }
          m -> xs
      }.toMap

      val list = map.toSeq.flatMap {
        case (k, v) =>
          val grouped = v.groupBy(_.revision).toSeq.sortBy(_._1).map { case (k, v) =>
            s"$k = [${v.map(_.projectId).sorted.mkString(", ")}]"
          }
          if (grouped.size > 1) {
            List(s"${k.groupId}:${k.artifactId} の複数のversionが混ざっています\n${grouped.map("  " + _).mkString("\n")}\n")
          } else {
            Nil
          }
      }
      if (list.nonEmpty) {
        sys.error(list.mkString("\n", "\n", ""))
      }
    }
  )

  override def projectSettings: Seq[Def.Setting[?]] =
    Def.settings(
      Seq(Compile, Test).flatMap(x =>
        x / externalDependencyModuleIds := {
          thisProjectRef.value.project -> (x / externalDependencyClasspath).value.flatMap(_.metadata.get(moduleID.key))
        },
      ),
    )
}

実際に以下のようなbuild.sbtで試すと、以下のような出力になります

val common = Seq(
  scalaVersion := "3.7.2"
)

common

lazy val a1 = project
  .settings(
    common,
    libraryDependencies += "org.playframework" %% "play" % "3.0.8"
  )

lazy val a2 = project
  .settings(
    common,
    libraryDependencies += "org.playframework" %% "play-json" % "3.0.4"
  )
[error] org.playframework:play-functional_3 の複数のversionが混ざっています
[error]   3.0.4 = [a2]
[error]   3.0.5 = [a1]
[error] 
[error] org.playframework:play-json_3 の複数のversionが混ざっています
[error]   3.0.4 = [a2]
[error]   3.0.5 = [a1]

一旦最低限雑に作ったのですが、CompileとTestの違いも検知するとか、細かい部分調整してOSSとしてしっかりgithubにあげてmaven centralにpublishするかもしれません

*1:sbt標準のevictedである程度可能な気がしなくもないが・・・どうなんだ?evictedだと自分たちのもの以外の間接的な依存全ての不整合を検知してしまう?