From 597266c894166ff436a002c50e7599ef22cb291e Mon Sep 17 00:00:00 2001 From: Russ White <356303+russwyte@users.noreply.github.com> Date: Fri, 28 Aug 2026 12:29:51 -0500 Subject: [PATCH] Add Ship and ShipGroup catalog rows with fail-closed bump and publish sets Typed outbound version rows in ZipxVersions, collected like Lib/Pin/Action. Pure Modver computes membership, group-lifted bump sets, and version-moved publish sets without touching plugin settings or planner emission. --- .../src/main/scala/zipx/ZipxVersions.scala | 5 +- .../core/src/main/scala/zipx/catalog.scala | 2 +- .../main/scala/zipx/core/ModuleGraph.scala | 8 + .../src/main/scala/zipx/core/Modver.scala | 254 +++++++++++ .../main/scala/zipx/core/ZipxCatalog.scala | 17 + .../src/main/scala/zipx/core/ZipxDep.scala | 61 +++ .../scala/zipx/core/ModuleGraphSpec.scala | 4 + .../src/test/scala/zipx/core/ModverSpec.scala | 401 ++++++++++++++++++ .../scala/zipx/core/ZipxCatalogSpec.scala | 29 ++ 9 files changed, 779 insertions(+), 2 deletions(-) create mode 100644 modules/core/src/main/scala/zipx/core/Modver.scala create mode 100644 modules/core/src/test/scala/zipx/core/ModverSpec.scala diff --git a/modules/core/src/main/scala/zipx/ZipxVersions.scala b/modules/core/src/main/scala/zipx/ZipxVersions.scala index 7529874..6eb7e36 100644 --- a/modules/core/src/main/scala/zipx/ZipxVersions.scala +++ b/modules/core/src/main/scala/zipx/ZipxVersions.scala @@ -1,7 +1,7 @@ package zipx /** Catalog surface a process that is not the target sbt can compile: `sbt` / `scala` / [[coords]] / [[pins]] / - * [[actions]]. No sbt types. + * [[actions]] / [[ships]]. No sbt types. * * Consumer files still extend [[ZipxVersions]] from the plugin, which adds `settings` / `deps` / `library`. The CLI * compiles that file against zipx jars on *its* classpath, not against the target `plugins.sbt`. @@ -21,4 +21,7 @@ trait Catalog: /** Every val on this object whose type has an [[AsActions]] given. */ inline def actions: Seq[Action] = zipx.core.ZipxCatalog.actionsOf[this.type](this) + + /** Every val on this object whose type has an [[AsShips]] given (`Ship`, `ShipGroup`, or a bundle). */ + inline def ships: Seq[PublishedRow] = zipx.core.ZipxCatalog.shipsOf[this.type](this) end Catalog diff --git a/modules/core/src/main/scala/zipx/catalog.scala b/modules/core/src/main/scala/zipx/catalog.scala index 5263364..6b0f0bf 100644 --- a/modules/core/src/main/scala/zipx/catalog.scala +++ b/modules/core/src/main/scala/zipx/catalog.scala @@ -16,4 +16,4 @@ val ZipxExclude = zipx.core.ZipxExclude type AsCoords[A] = zipx.core.AsCoords[A] val AsCoords = zipx.core.AsCoords -export zipx.core.{AsActions, AsPins, Action, Pin, PinFeedName, Purl} +export zipx.core.{AsActions, AsPins, AsShips, Action, Pin, PinFeedName, Purl, PublishedRow, Ship, ShipGroup} diff --git a/modules/core/src/main/scala/zipx/core/ModuleGraph.scala b/modules/core/src/main/scala/zipx/core/ModuleGraph.scala index cda9b65..c4a91e8 100644 --- a/modules/core/src/main/scala/zipx/core/ModuleGraph.scala +++ b/modules/core/src/main/scala/zipx/core/ModuleGraph.scala @@ -85,6 +85,8 @@ end ModuleId * @param docker * whether this module publishes a docker image (has sbt-native-packager's Docker plugin enabled / opted in). Drives * the docker capability's per-module jobs. + * @param matrixRootOpt + * override for [[matrixRoot]]. `None` means the root is [[id]]. */ final case class ModuleNode( id: ModuleId, @@ -97,8 +99,14 @@ final case class ModuleNode( baseDir: String = "", sourcePaths: List[String] = Nil, docker: Boolean = false, + matrixRootOpt: Option[ModuleId] = None, ): + /** The sbt project id a [[Ship]] names. A `projectMatrix` JVM row is `core` and its JS row is `coreJS`; both share + * `matrixRoot = core`. Defaults to [[id]] so existing fixtures keep compiling. + */ + def matrixRoot: ModuleId = matrixRootOpt.getOrElse(id) + /** Every path this module owns for affected-gating: [[baseDir]] and its [[sourcePaths]]. * * A union, so recording source paths only ever *adds* ownership. `baseDir` still answers for a module's non-source diff --git a/modules/core/src/main/scala/zipx/core/Modver.scala b/modules/core/src/main/scala/zipx/core/Modver.scala new file mode 100644 index 0000000..fc4d19d --- /dev/null +++ b/modules/core/src/main/scala/zipx/core/Modver.scala @@ -0,0 +1,254 @@ +package zipx.core + +/** Identity of a catalog row: a lone [[Ship]]'s project id, or a [[ShipGroup]]'s name. Not a platform row id. */ +enum ShipRef: + case One(id: ModuleId) + case Group(name: ShipGroupName) + +final case class ShipIndex( + byIdentity: Map[ShipRef, PublishedRow], + byRoot: Map[ModuleId, PublishedRow], +): + def refOf(row: PublishedRow): ShipRef = ShipIndex.refOf(row) + + def rowFor(id: ModuleId): Option[PublishedRow] = byRoot.get(id) + + def liftGroups(dirtyRoots: Set[ModuleId]): Set[ShipRef] = + dirtyRoots.flatMap(byRoot.get).map(refOf).toSet +end ShipIndex + +object ShipIndex: + val empty: ShipIndex = ShipIndex(Map.empty, Map.empty) + + def refOf(row: PublishedRow): ShipRef = row match + case s: Ship => ShipRef.One(s.id) + case g: ShipGroup => ShipRef.Group(g.name) + + def from(rows: Seq[PublishedRow]): ShipIndex = + val byIdentity = rows.map(r => refOf(r) -> r).toMap + val byRoot = rows.flatMap(r => r.memberRoots.map(_ -> r)).toMap + ShipIndex(byIdentity, byRoot) +end ShipIndex + +/** Min-bump map after lift (and, later, after propagate). */ +opaque type BumpSet = Map[ShipRef, BumpKind] +object BumpSet: + def empty: BumpSet = Map.empty + def apply(m: Map[ShipRef, BumpKind]): BumpSet = m + extension (b: BumpSet) def asMap: Map[ShipRef, BumpKind] = b + +enum RegistryStatus: + case Published, Missing + +/** Maven GAV as published, including the Scala-binary suffix on `artifact`. */ +final case class Gav(organization: String, artifact: String, version: String) + +final case class MovedRows( + versionChanged: Set[ShipRef], + added: Set[ShipRef], + newMembers: Set[ModuleId], +) + +object MovedRows: + val empty: MovedRows = MovedRows(Set.empty, Set.empty, Set.empty) + +/** Fail-closed bump and publish sets. Verify's [[Affected]] stays a sibling; do not call it from here. */ +object Modver: + + /** `None < Patch < Minor < Major`. [[BumpKind.PreRelease]] is not a min-bump. */ + val minBumpOrd: _root_.scala.math.Ordering[BumpKind] = + _root_.scala.math.Ordering.by { + case BumpKind.None => 0 + case BumpKind.Patch => 1 + case BumpKind.Minor => 2 + case BumpKind.Major => 3 + case BumpKind.PreRelease => 0 + } + + def describe(row: PublishedRow): String = row match + case s: Ship => s"""Ship("${s.id}")""" + case g: ShipGroup => s"""ShipGroup("${g.name}")""" + + def publishingRoots(graph: ModuleGraph): Set[ModuleId] = + graph.nodes.filter(_.publishes).map(_.matrixRoot).toSet + + def rowFor(projectId: ModuleId, ships: Seq[PublishedRow]): Option[PublishedRow] = + ships.find(_.memberRoots.contains(projectId)) + + /** PR 1 stub: later rounds run MiMa then propagate. Identity so MatchBump cannot see Patch placeholders. */ + def expand(bumps: BumpSet, graph: ModuleGraph, ships: ShipIndex): BumpSet = + val _ = (graph, ships) + bumps + + /** Owning published matrix roots. Empty file list is empty set, not all. `.sbt` / `project/` do not expand. */ + def dirtyRoots(graph: ModuleGraph, changedFiles: List[String]): Set[ModuleId] = + changedFiles + .flatMap(path => Affected.owningModules(graph, path)) + .flatMap(id => graph.get(id).toList) + .filter(_.publishes) + .map(_.matrixRoot) + .toSet + + /** Fail closed: None files => Left. Kinds are not Patch placeholders. */ + def liftedBumpSet( + graph: ModuleGraph, + ships: ShipIndex, + changedFiles: Option[List[String]], + ): Either[String, Set[ShipRef]] = + changedFiles match + case None => Left("could not diff changed files for modver; refusing to guess the bump set") + case Some(files) => Right(ships.liftGroups(dirtyRoots(graph, files))) + + /** Missing catalog at `before` is empty (first adoption). Failed `git show` / parse is Left. */ + def previousIndex( + shown: Either[String, Option[String]], + parse: String => Either[String, ShipIndex], + ): Either[String, ShipIndex] = + shown match + case Left(err) => Left(err) + case Right(None) => Right(ShipIndex.empty) + case Right(Some(src)) => parse(src) + + /** Catalog diff. `previous` Left is git/parse failure. Right(empty) is first adoption, not a refusal. */ + def movedRows( + current: ShipIndex, + previous: Either[String, ShipIndex], + ): Either[String, MovedRows] = + previous.map(prev => diff(current, prev)) + + def thisCommitReleases(row: PublishedRow, moved: MovedRows, index: ShipIndex): Boolean = + val ref = index.refOf(row) + moved.versionChanged.contains(ref) || moved.added.contains(ref) || + row.memberRoots.exists(moved.newMembers.contains) + + /** Every platform row of a moved row, then drop GAVs already on the registry. Fail closed on lookup errors. */ + def filterUnpublished( + moved: MovedRows, + index: ShipIndex, + graph: ModuleGraph, + gavs: ModuleId => List[Gav], + registry: Gav => Either[String, RegistryStatus], + ): Either[String, Map[ModuleId, List[Gav]]] = + val roots = candidateRoots(moved, index) + val modules = graph.nodes.filter(n => roots.contains(n.matrixRoot)) + modules.foldLeft[Either[String, Map[ModuleId, List[Gav]]]](Right(Map.empty)) { (acc, node) => + acc.flatMap { m => + missingGavs(gavs(node.id), registry).map { missing => + if missing.isEmpty then m else m + (node.id -> missing) + } + } + } + end filterUnpublished + + def membership(graph: ModuleGraph, ships: Seq[PublishedRow]): Either[String, ShipIndex] = + for + _ <- firstError(ships.flatMap(ciVersionError)) + _ <- firstError(ships.flatMap(emptyGroupError)) + _ <- firstError(duplicateIdentityErrors(ships)) + _ <- firstError(ships.flatMap(memberErrors(graph, _))) + _ <- overlapError(ships) + _ <- uncoveredError(graph, ships) + yield ShipIndex.from(ships) + + private def firstError(errs: Seq[String]): Either[String, Unit] = + errs.headOption.toLeft(()) + + private def ciVersionError(row: PublishedRow): Option[String] = + val ver = row.version: String + Option.when(ver.endsWith("-ci")) { + s"${describe(row)} version '$ver' must be the release number, not a -ci suffix." + } + + private def emptyGroupError(row: PublishedRow): Option[String] = row match + case g: ShipGroup if g.members.isEmpty => Some(s"""ShipGroup("${g.name}") has no members.""") + case _ => None + + private def duplicateIdentityErrors(ships: Seq[PublishedRow]): List[String] = + val shipsById = ships.collect { case s: Ship => s }.groupBy(_.id) + val groupsByN = ships.collect { case g: ShipGroup => g }.groupBy(_.name) + shipsById.collect { + case (id, copies) if copies.size > 1 => s"""Ship("$id") appears twice.""" + }.toList ++ groupsByN.collect { + case (name, copies) if copies.size > 1 => s"""ShipGroup name '$name' appears twice.""" + }.toList + + private def memberErrors(graph: ModuleGraph, row: PublishedRow): List[String] = + row.memberRoots.flatMap { root => + graph.nodes.find(_.id == (root: String)) match + case None => + row match + case _: Ship => List(s"""Ship("$root") is not an sbt project id.""") + case g: ShipGroup => List(s"""ShipGroup("${g.name}") member '$root' is not an sbt project id.""") + case Some(node) if node.matrixRoot != root => + List( + s"""Ship("$root") names a platform row; use Ship("${node.matrixRoot}", …) for the matrix root.""" + ) + case Some(node) if !node.publishes => + row match + case _: Ship => + List(s"""Ship("$root") does not publish. Drop it or set publish / skip := false.""") + case g: ShipGroup => + List( + s"""ShipGroup("${g.name}") member '$root' does not publish. Drop it or set publish / skip := false.""" + ) + case Some(_) => Nil + } + + private def overlapError(ships: Seq[PublishedRow]): Either[String, Unit] = + val byRoot = ships.flatMap(r => r.memberRoots.map(_ -> r)).groupMap(_._1)(_._2) + val clash = byRoot.collect { + case (root, rows) if rows.distinct.size > 1 => + val listed = rows.distinct.map(describe).sorted.mkString(" and ") + s"published module '$root' is in $listed. Each publishes=true module must be in exactly one row." + }.headOption + clash.toLeft(()) + + private def uncoveredError(graph: ModuleGraph, ships: Seq[PublishedRow]): Either[String, Unit] = + val covered = ships.flatMap(_.memberRoots).toSet + val missing = publishingRoots(graph).toList + .sortBy(id => id: String) + .collect { + case root if !covered.contains(root) => + s"""published module '$root' is not in a Ship or ShipGroup. Add Ship("$root", "…") or a ShipGroup member.""" + } + .headOption + missing.toLeft(()) + end uncoveredError + + private def diff(current: ShipIndex, previous: ShipIndex): MovedRows = + val currentRefs = current.byIdentity.keySet + val prevRefs = previous.byIdentity.keySet + val added = currentRefs -- prevRefs + val versionChanged = (currentRefs intersect prevRefs).filter { ref => + val now = current.byIdentity(ref).version: String + val was = previous.byIdentity(ref).version: String + now != was + } + val newMembers = current.byIdentity.flatMap { (ref, row) => + previous.byIdentity.get(ref) match + case None => Set.empty[ModuleId] + case Some(prev) => row.memberRoots.toSet -- prev.memberRoots.toSet + }.toSet + MovedRows(versionChanged, added, newMembers) + end diff + + private def candidateRoots(moved: MovedRows, index: ShipIndex): Set[ModuleId] = + val fromRows = (moved.versionChanged ++ moved.added).flatMap { ref => + index.byIdentity.get(ref).toList.flatMap(_.memberRoots) + } + val fromNew = moved.newMembers.flatMap(id => index.byRoot.get(id).toList.flatMap(_.memberRoots)) + fromRows ++ fromNew ++ moved.newMembers + + private def missingGavs( + gavs: List[Gav], + registry: Gav => Either[String, RegistryStatus], + ): Either[String, List[Gav]] = + gavs.foldLeft[Either[String, List[Gav]]](Right(Nil)) { (acc, gav) => + acc.flatMap { missing => + registry(gav).map { + case RegistryStatus.Missing => missing :+ gav + case RegistryStatus.Published => missing + } + } + } +end Modver diff --git a/modules/core/src/main/scala/zipx/core/ZipxCatalog.scala b/modules/core/src/main/scala/zipx/core/ZipxCatalog.scala index d3a806d..0bb39a4 100644 --- a/modules/core/src/main/scala/zipx/core/ZipxCatalog.scala +++ b/modules/core/src/main/scala/zipx/core/ZipxCatalog.scala @@ -264,6 +264,10 @@ object ZipxCatalog: inline def actionsOf[A](inline catalog: A): Seq[Action] = ${ actionsOfImpl[A]('catalog) } + /** Every val on `catalog` whose type has an [[AsShips]] given. Same walk as [[coordsOf]]. */ + inline def shipsOf[A](inline catalog: A): Seq[PublishedRow] = + ${ shipsOfImpl[A]('catalog) } + private def coordsOfImpl[A: Type](catalog: Expr[A])(using Quotes): Expr[Seq[ZipxCoord]] = import quotes.reflect.* val parts = catalogValParts[A].flatMap { (sym, mt) => @@ -303,6 +307,19 @@ object ZipxCatalog: if parts.isEmpty then '{ Seq.empty[Action] } else '{ ${ Expr.ofList(parts) }.flatten } end actionsOfImpl + private def shipsOfImpl[A: Type](catalog: Expr[A])(using Quotes): Expr[Seq[PublishedRow]] = + import quotes.reflect.* + val parts = catalogValParts[A].flatMap { (sym, mt) => + mt.asType match + case '[t] => + Expr.summon[AsShips[t]].map { tc => + val field = Select.unique(catalog.asTerm, sym.name).asExprOf[t] + '{ $tc.ships($field) } + } + } + if parts.isEmpty then '{ Seq.empty[PublishedRow] } else '{ ${ Expr.ofList(parts) }.flatten } + end shipsOfImpl + private def catalogValParts[A: Type](using Quotes): List[(quotes.reflect.Symbol, quotes.reflect.TypeRepr)] = import quotes.reflect.* diff --git a/modules/core/src/main/scala/zipx/core/ZipxDep.scala b/modules/core/src/main/scala/zipx/core/ZipxDep.scala index 3b69600..c1063c4 100644 --- a/modules/core/src/main/scala/zipx/core/ZipxDep.scala +++ b/modules/core/src/main/scala/zipx/core/ZipxDep.scala @@ -88,6 +88,67 @@ object AsActions: given ofAction: AsActions[Action] = a => Seq(a) +/** How a catalog val becomes outbound version rows. [[Ship]] / [[ShipGroup]] have givens; a bundle can add its own. */ +trait AsShips[A]: + def ships(value: A): Seq[PublishedRow] + +object AsShips: + def apply[A](using ev: AsShips[A]): AsShips[A] = ev + + given ofRow[A <: PublishedRow]: AsShips[A] = a => Seq(a) + +/** Identity of a [[ShipGroup]] (`foo` in `ShipGroup("foo", "1.4.2")(...)`). */ +type ShipGroupName = ShipGroupName.Type +object ShipGroupName extends Subtype[String]: + override inline def validate(input: String): Boolean | String = + if input.nonEmpty then true else "a ship group name must be non-empty" + +/** One outbound version row: a lone [[Ship]] or a [[ShipGroup]] whose members share a number. */ +sealed trait PublishedRow: + def version: DepVersion + + /** `"Ship"` or `"ShipGroup"`, for comments and apply. */ + def label: String + + /** Project id or group name. */ + def identity: String + + /** Matrix roots this row owns. */ + def memberRoots: List[ModuleId] +end PublishedRow + +final case class Ship(id: ModuleId, version: DepVersion) extends PublishedRow: + def label: String = "Ship" + def identity: String = id + def memberRoots: List[ModuleId] = List(id) + +object Ship: + /** Catalog literal. `@targetName` plus `new` because [[ModuleId]] / [[DepVersion]] erase to `String` and would clash + * with the case-class apply. Same pattern as [[Action.apply]] (`Lib` / `Plugin` dodge it with extra defaults; Ship + * has none). + */ + @targetName("fromLiterals") + inline def apply(inline id: String, inline version: String): Ship = + new Ship(ModuleId(id), DepVersion(version)) + +final case class ShipGroup( + name: ShipGroupName, + version: DepVersion, + members: List[ModuleId], +) extends PublishedRow: + def label: String = "ShipGroup" + def identity: String = name + def memberRoots: List[ModuleId] = members + +object ShipGroup: + /** Catalog literal. Member ids are runtime strings (`String*`), so they cannot use inline [[ModuleId.apply]]. */ + inline def apply(inline name: String, inline version: String)(members: String*): ShipGroup = + new ShipGroup( + ShipGroupName(name), + DepVersion(version), + members.iterator.map(ModuleId.unsafeMake).toList, + ) + /** A full git commit SHA (40 hex). Stricter than [[zipx.workflow.ActionRef]], which still allows tags. */ type GitSha = GitSha.Type object GitSha extends Subtype[String]: diff --git a/modules/core/src/test/scala/zipx/core/ModuleGraphSpec.scala b/modules/core/src/test/scala/zipx/core/ModuleGraphSpec.scala index 9ae5b04..a8d1115 100644 --- a/modules/core/src/test/scala/zipx/core/ModuleGraphSpec.scala +++ b/modules/core/src/test/scala/zipx/core/ModuleGraphSpec.scala @@ -6,6 +6,10 @@ object ModuleGraphSpec extends ZIOSpecDefault: import Fixtures.* def spec = suite("ModuleGraph")( + test("matrixRoot defaults to id so existing fixtures keep compiling") { + val n = ModuleNode(ModuleId("schema"), publishes = true) + assertTrue(n.matrixRoot == n.id, (n.matrixRoot: String) == "schema") + }, test("topological sort places dependencies before dependents") { val order = sampleGraph.topologicalSort def idx(id: String) = order.indexOf(id) diff --git a/modules/core/src/test/scala/zipx/core/ModverSpec.scala b/modules/core/src/test/scala/zipx/core/ModverSpec.scala new file mode 100644 index 0000000..eb331b5 --- /dev/null +++ b/modules/core/src/test/scala/zipx/core/ModverSpec.scala @@ -0,0 +1,401 @@ +package zipx.core + +import zio.test.* + +object ModverSpec extends ZIOSpecDefault: + + private def mid(s: String): ModuleId = ModuleId.unsafeMake(s) + private def depVer(s: String): DepVersion = DepVersion.unsafeMake(s) + private def gname(s: String): ShipGroupName = ShipGroupName.unsafeMake(s) + + private def node( + id: String, + publishes: Boolean = true, + deps: List[String] = Nil, + base: String = "", + sources: List[String] = Nil, + root: String = "", + ): ModuleNode = + val matrixRoot = if root.isEmpty then id else root + ModuleNode( + id = mid(id), + dependsOn = deps, + publishes = publishes, + baseDir = if base.isEmpty then id else base, + sourcePaths = sources, + matrixRootOpt = Option.when(matrixRoot != id)(mid(matrixRoot)), + ) + end node + + private val graph = GraphFixture( + List( + node("models"), + node("coreLib", deps = List("models"), base = "core-lib"), + node("client", deps = List("coreLib")), + node("service", publishes = false, deps = List("coreLib")), + ) + ) + + private val libs = ShipGroup("libs", "1.4.2")("models", "coreLib") + private val client = Ship("client", "0.3.0") + private val covering = List[PublishedRow](libs, client) + + private val index = Modver.membership(graph, covering) match + case Right(i) => i + case Left(err) => throw AssertionError(s"covering fixture is invalid: $err") + + private val matrix = GraphFixture( + List( + node( + "core", + base = ".sbt/matrix/core", + sources = List("core/src/main/scala", "core/src/main/scalajvm"), + ), + node( + "coreJS", + base = ".sbt/matrix/coreJS", + sources = List("core/src/main/scala", "core/src/main/scalajs"), + root = "core", + ), + node("cli", deps = List("coreJS")), + ) + ) + + private val gVersion: Gen[Any, String] = + for + major <- Gen.int(0, 9) + minor <- Gen.int(0, 9) + patch <- Gen.int(0, 9) + yield s"$major.$minor.$patch" + + private def gChunks(ids: List[String]): Gen[Any, List[List[String]]] = + Gen.suspend { + if ids.isEmpty then Gen.const(Nil) + else + for + take <- Gen.int(1, math.min(3, ids.size)) + rest <- gChunks(ids.drop(take)) + yield ids.take(take) :: rest + } + + private val gCovered: Gen[Any, (ModuleGraph, List[PublishedRow])] = + for + n <- Gen.int(2, 6) + unpublished <- Gen.boolean + ver <- gVersion + ids = (0 until n).map(i => s"mod$i").toList + chunks <- gChunks(ids) + yield + val nodes = + ids.map(id => node(id)) ++ + Option.when(unpublished)(node("skip", publishes = false)).toList + val rows = chunks.zipWithIndex.map { + case (List(one), _) => Ship(mid(one), depVer(ver)) + case (members, i) => + ShipGroup(gname(s"g$i"), depVer(ver), members.map(mid)) + } + (GraphFixture(nodes), rows) + + def spec = suite("Modver")( + suite("literals")( + test("Ship and ShipGroup catalog literals typecheck") { + val ship: Ship = Ship("core", "1.4.2") + val group = ShipGroup("foo", "1.4.2")("foo-api", "foo-cli", "foo-impl") + val emptyMembers = ShipGroup("empty", "1.0.0")() + assertTrue( + (ship.id: String) == "core", + (ship.version: String) == "1.4.2", + (group.name: String) == "foo", + group.members.map(m => m: String) == List("foo-api", "foo-cli", "foo-impl"), + emptyMembers.members.isEmpty, + ) + }, + test("a Ship literal is rejected while the catalog compiles, not when it runs") { + for + good <- typeCheck("""zipx.core.Ship("core", "1.4.2")""") + badId <- typeCheck("""zipx.core.Ship("café", "1.4.2")""") + badName <- typeCheck("""zipx.core.ShipGroup("", "1.4.2")("core")""") + yield assertTrue(good.isRight, badId.isLeft, badName.isLeft) + }, + ), + suite("membership")( + test("a covering catalog is Right and every publishing root is in exactly one row") { + check(gCovered) { (g, rows) => + Modver.membership(g, rows) match + case Left(err) => assertTrue(err == "") + case Right(built) => + val roots = Modver.publishingRoots(g) + assertTrue( + roots.forall(built.byRoot.contains), + roots.forall(r => built.byRoot(r).memberRoots.contains(r)), + built.byRoot.keySet == roots, + ) + } + }, + test("an unpublished module is not a membership hole") { + assertTrue(Modver.membership(graph, covering).isRight, !index.byRoot.contains(ModuleId("service"))) + }, + test("a missing publishing root names the Ship constructor") { + val err = Modver.membership(graph, List(client)) + assertTrue( + err.isLeft, + err.swap.exists(_.contains("published module 'coreLib' is not in a Ship or ShipGroup")), + err.swap.exists(_.contains("""Add Ship("coreLib", "…")""")), + ) + }, + test("the same root in two rows names both constructors") { + val extra = Ship("models", "9.0.0") + val err = Modver.membership(graph, covering :+ extra) + assertTrue( + err.isLeft, + err.swap.exists(_.contains("published module 'models' is in")), + err.swap.exists(_.contains("""Ship("models")""")), + err.swap.exists(_.contains("""ShipGroup("libs")""")), + err.swap.exists(_.contains("exactly one row")), + ) + }, + test("a duplicate Ship id is refused") { + val err = Modver.membership(graph, covering :+ Ship("client", "0.4.0")) + assertTrue(err.swap.exists(_ == """Ship("client") appears twice.""")) + }, + test("a duplicate ShipGroup name is refused") { + val other = ShipGroup("libs", "9.0.0")("client") + val err = Modver.membership(graph, List(libs, other, client)) + assertTrue(err.swap.exists(_ == """ShipGroup name 'libs' appears twice.""")) + }, + test("an empty ShipGroup is refused") { + val err = Modver.membership(graph, covering :+ ShipGroup("foo", "1.0.0")()) + assertTrue(err.swap.exists(_ == """ShipGroup("foo") has no members.""")) + }, + test("an unknown group member is refused") { + val err = Modver.membership(graph, covering :+ ShipGroup("x", "1.0.0")("nope")) + assertTrue(err.swap.exists(_ == """ShipGroup("x") member 'nope' is not an sbt project id.""")) + }, + test("an unpublished group member is refused") { + val err = Modver.membership(graph, covering :+ ShipGroup("apps", "1.0.0")("service")) + assertTrue( + err.swap.exists( + _ == """ShipGroup("apps") member 'service' does not publish. Drop it or set publish / skip := false.""" + ) + ) + }, + test("a -ci catalog version is refused") { + val err = Modver.membership(graph, List(ShipGroup("libs", "1.4.2-ci")("models", "coreLib"), client)) + assertTrue( + err.swap.exists( + _ == """ShipGroup("libs") version '1.4.2-ci' must be the release number, not a -ci suffix.""" + ) + ) + }, + test("a Ship of a platform row names the matrix root") { + val err = Modver.membership(matrix, List(Ship("coreJS", "1.4.2"), Ship("cli", "0.3.0"))) + assertTrue( + err.swap.exists( + _ == """Ship("coreJS") names a platform row; use Ship("core", …) for the matrix root.""" + ) + ) + }, + test("one Ship of the matrix root covers every platform row") { + val rows = List[PublishedRow](Ship("core", "1.4.2"), Ship("cli", "0.3.0")) + Modver.membership(matrix, rows) match + case Left(err) => assertTrue(err == "") + case Right(built) => + assertTrue( + built.byRoot.get(ModuleId("core")).contains(Ship("core", "1.4.2")), + built.rowFor(ModuleId("core")).isDefined, + ) + }, + ), + suite("bump set")( + test("None files refuse rather than fail open") { + val err = Modver.liftedBumpSet(graph, index, None) + assertTrue(err == Left("could not diff changed files for modver; refusing to guess the bump set")) + }, + test("an empty diff is an empty bump set, not all modules") { + assertTrue(Modver.liftedBumpSet(graph, index, Some(Nil)) == Right(Set.empty)) + }, + test("a build-file change does not explode the bump set") { + val files = List("build.sbt", "project/ZipxVersions.scala", "project/plugins.sbt") + assertTrue( + Modver.dirtyRoots(graph, files).isEmpty, + Modver.liftedBumpSet(graph, index, Some(files)) == Right(Set.empty), + ) + }, + test("any dirty group member lifts to the group, not each member") { + val lifted = Modver.liftedBumpSet(graph, index, Some(List("core-lib/src/main/scala/Core.scala"))) + assertTrue(lifted == Right(Set(ShipRef.Group(ShipGroupName("libs"))))) + }, + test("a leaf change does not reverse-dep into the bump set") { + val lifted = Modver.liftedBumpSet(graph, index, Some(List("client/src/main/scala/Client.scala"))) + assertTrue( + lifted == Right(Set(ShipRef.One(ModuleId("client")))), + !lifted.exists(_.contains(ShipRef.Group(ShipGroupName("libs")))), + ) + }, + test("shared matrix sources dirty the root once") { + val covered = List[PublishedRow](Ship("core", "1.4.2"), Ship("cli", "0.3.0")) + val built = Modver.membership(matrix, covered).toOption.get + val lifted = Modver.liftedBumpSet(matrix, built, Some(List("core/src/main/scala/Foo.scala"))) + assertTrue(lifted == Right(Set(ShipRef.One(ModuleId("core"))))) + }, + test("expand is identity") { + val bumps = BumpSet(Map(ShipRef.One(ModuleId("client")) -> BumpKind.Major)) + assertTrue(Modver.expand(bumps, graph, index).asMap == bumps.asMap) + }, + test("min-bump order is None then Patch then Minor then Major") { + import Modver.minBumpOrd + assertTrue( + minBumpOrd.lt(BumpKind.None, BumpKind.Patch), + minBumpOrd.lt(BumpKind.Patch, BumpKind.Minor), + minBumpOrd.lt(BumpKind.Minor, BumpKind.Major), + minBumpOrd.max(BumpKind.Patch, BumpKind.Major) == BumpKind.Major, + ) + }, + ), + suite("movedRows")( + test("a missing previous catalog is empty, not Left") { + val parsed = Modver.previousIndex(Right(None), _ => Left("should not parse")) + assertTrue(parsed == Right(ShipIndex.empty)) + }, + test("a failed git show is Left") { + assertTrue( + Modver.previousIndex(Left("git show failed"), _ => Right(ShipIndex.empty)) == Left("git show failed") + ) + }, + test("a parse error is Left") { + val parsed = Modver.previousIndex(Right(Some("not scala")), _ => Left("parse failed")) + assertTrue(parsed == Left("parse failed")) + }, + test("first adoption adds every current identity") { + Modver.movedRows(index, Right(ShipIndex.empty)) match + case Left(err) => assertTrue(err == "") + case Right(moved) => + assertTrue( + moved.added == Set(ShipRef.Group(ShipGroupName("libs")), ShipRef.One(ModuleId("client"))), + moved.versionChanged.isEmpty, + moved.newMembers.isEmpty, + ) + }, + test("a failed previous index is Left") { + assertTrue(Modver.movedRows(index, Left("no git")).isLeft) + }, + test("a version-equal catalog is not moved") { + assertTrue(Modver.movedRows(index, Right(index)) == Right(MovedRows.empty)) + }, + test("a version change is versionChanged, not added") { + val prev = ShipIndex.from(List(ShipGroup("libs", "1.4.1")("models", "coreLib"), client)) + Modver.movedRows(index, Right(prev)) match + case Left(err) => assertTrue(err == "") + case Right(moved) => + assertTrue( + moved.versionChanged == Set(ShipRef.Group(ShipGroupName("libs"))), + moved.added.isEmpty, + moved.newMembers.isEmpty, + ) + }, + test("a new group member is in newMembers without a version bump") { + val prev = ShipIndex.from(List(ShipGroup("libs", "1.4.2")("models"), client)) + Modver.movedRows(index, Right(prev)) match + case Left(err) => assertTrue(err == "") + case Right(moved) => + assertTrue( + moved.newMembers == Set(ModuleId("coreLib")), + moved.versionChanged.isEmpty, + moved.added.isEmpty, + ) + }, + ), + suite("filterUnpublished")( + test("every member of a version-moved group is in the publish set") { + val moved = MovedRows( + versionChanged = Set(ShipRef.Group(ShipGroupName("libs"))), + added = Set.empty, + newMembers = Set.empty, + ) + val gav = (id: ModuleId) => List(Gav("org", s"${id}_3", "1.4.2")) + Modver.filterUnpublished(moved, index, graph, gav, _ => Right(RegistryStatus.Missing)) match + case Left(err) => assertTrue(err == "") + case Right(pub) => + assertTrue( + pub.keySet == Set(ModuleId("models"), ModuleId("coreLib")), + !pub.contains(ModuleId("client")), + ) + }, + test("a new group member publishes; version-unchanged siblings skip when already on the registry") { + val moved = MovedRows(Set.empty, Set.empty, newMembers = Set(ModuleId("coreLib"))) + val gav = (id: ModuleId) => List(Gav("org", s"${id}_3", "1.4.2")) + val reg: Gav => Either[String, RegistryStatus] = + g => + if g.artifact.startsWith("coreLib") then Right(RegistryStatus.Missing) + else Right(RegistryStatus.Published) + Modver.filterUnpublished(moved, index, graph, gav, reg) match + case Left(err) => assertTrue(err == "") + case Right(pub) => + assertTrue( + pub.keySet == Set(ModuleId("coreLib")), + !pub.contains(ModuleId("models")), + ) + }, + test("a version-unchanged sibling whose GAV is missing still publishes") { + val moved = MovedRows(Set.empty, Set.empty, newMembers = Set(ModuleId("coreLib"))) + val gav = (id: ModuleId) => List(Gav("org", s"${id}_3", "1.4.2")) + Modver.filterUnpublished(moved, index, graph, gav, _ => Right(RegistryStatus.Missing)) match + case Left(err) => assertTrue(err == "") + case Right(pub) => assertTrue(pub.keySet == Set(ModuleId("models"), ModuleId("coreLib"))) + }, + test("mixed binaries keep only the Missing GAVs") { + val moved = + MovedRows(versionChanged = Set(ShipRef.One(ModuleId("client"))), added = Set.empty, newMembers = Set.empty) + val g213 = Gav("org", "client_2.13", "0.3.1") + val g3 = Gav("org", "client_3", "0.3.1") + val gav = (_: ModuleId) => List(g213, g3) + val reg: Gav => Either[String, RegistryStatus] = + g => if g.artifact.endsWith("_3") then Right(RegistryStatus.Missing) else Right(RegistryStatus.Published) + Modver.filterUnpublished(moved, index, graph, gav, reg) match + case Left(err) => assertTrue(err == "") + case Right(pub) => assertTrue(pub.get(ModuleId("client")).contains(List(g3))) + }, + test("a registry lookup error is Left") { + val moved = + MovedRows(versionChanged = Set(ShipRef.One(ModuleId("client"))), added = Set.empty, newMembers = Set.empty) + val err = + Modver.filterUnpublished( + moved, + index, + graph, + _ => List(Gav("org", "client_3", "0.3.1")), + _ => Left("HTTP 500"), + ) + assertTrue(err == Left("HTTP 500")) + }, + test("both platform rows of a moved root are candidates") { + val rows = List[PublishedRow](Ship("core", "1.4.2"), Ship("cli", "0.3.0")) + val built = Modver.membership(matrix, rows).toOption.get + val moved = + MovedRows(versionChanged = Set(ShipRef.One(ModuleId("core"))), added = Set.empty, newMembers = Set.empty) + val gav = (id: ModuleId) => List(Gav("org", s"${id}_3", "1.4.2")) + Modver.filterUnpublished(moved, built, matrix, gav, _ => Right(RegistryStatus.Missing)) match + case Left(err) => assertTrue(err == "") + case Right(pub) => assertTrue(pub.keySet == Set(ModuleId("core"), ModuleId("coreJS"))) + }, + ), + suite("thisCommitReleases")( + test("a version change, a first add, or a new member releases that row") { + val versioned = MovedRows( + versionChanged = Set(ShipRef.Group(ShipGroupName("libs"))), + added = Set.empty, + newMembers = Set.empty, + ) + val added = MovedRows(Set.empty, added = Set(ShipRef.One(ModuleId("client"))), newMembers = Set.empty) + val member = MovedRows(Set.empty, Set.empty, newMembers = Set(ModuleId("coreLib"))) + assertTrue( + Modver.thisCommitReleases(libs, versioned, index), + !Modver.thisCommitReleases(client, versioned, index), + Modver.thisCommitReleases(client, added, index), + Modver.thisCommitReleases(libs, member, index), + !Modver.thisCommitReleases(client, member, index), + ) + } + ), + ) +end ModverSpec diff --git a/modules/core/src/test/scala/zipx/core/ZipxCatalogSpec.scala b/modules/core/src/test/scala/zipx/core/ZipxCatalogSpec.scala index e5b19c4..661f310 100644 --- a/modules/core/src/test/scala/zipx/core/ZipxCatalogSpec.scala +++ b/modules/core/src/test/scala/zipx/core/ZipxCatalogSpec.scala @@ -233,6 +233,35 @@ object ZipxCatalogSpec extends ZIOSpecDefault: out.contains("""Plugin("org.scalameta", "sbt-scalafmt", "2.7.0")"""), ) }, + test("applyBumps does not rewrite a coincidental version inside a Ship constructor") { + val src = + """ + |val zio = Lib("dev.zio", "zio", "1.4.2") + |val core = Ship("core", "1.4.2") + |val foo = ShipGroup("foo", "1.4.2")("foo-api", "foo-cli") + |""".stripMargin + val bumps = List(DepBump(Lib("dev.zio", "zio", "1.4.2"), BumpKind.Patch, "1.4.3")) + ZipxCatalog.applyBumps(src, bumps) match + case Left(err) => assertTrue(err.isEmpty) + case Right(out) => + assertTrue( + out.contains("""Lib("dev.zio", "zio", "1.4.3")"""), + out.contains("""Ship("core", "1.4.2")"""), + out.contains("""ShipGroup("foo", "1.4.2")("foo-api", "foo-cli")"""), + ) + }, + test("shipsOf collects Ship and ShipGroup vals and skips Lib, lists, and defs") { + trait Catalog: + inline def ships: Seq[PublishedRow] = ZipxCatalog.shipsOf[this.type](this) + object Sample extends Catalog: + val zio = Lib("dev.zio", "zio", "2.1.26") + val core = Ship("core", "1.4.2") + val foo = ShipGroup("foo", "1.4.2")("foo-api", "foo-cli") + val listed = List(core) + def unused = core + val labels = Sample.ships.map(r => s"${r.label}:${r.identity}") + assertTrue(labels == List("Ship:core", "ShipGroup:foo")) + }, test("pinsOf collects Pin vals and skips Lib, lists, and defs") { trait Catalog: inline def pins: Seq[Pin] = ZipxCatalog.pinsOf[this.type](this)