diff --git a/main/src/main/scala/sbt/internal/SettingGraph.scala b/main/src/main/scala/sbt/internal/SettingGraph.scala index cc27a5ca4..1a7b7a1de 100644 --- a/main/src/main/scala/sbt/internal/SettingGraph.scala +++ b/main/src/main/scala/sbt/internal/SettingGraph.scala @@ -96,11 +96,20 @@ object Graph { }) + s.slice(at + 1, s.length) else s - def toAsciiLines(node: A, level: Int, parents: Set[A]): Vector[String] = + // Owned by toAsciiLines; grows monotonically over one render. + import scala.collection.mutable + val visited = mutable.Set.empty[A] + def toAsciiLines(node: A, level: Int, parents: Set[A]): Vector[String] = { + val prefix = if (level == 0) "" else "+-" if (parents contains node) // cycle Vector(limitLine((twoSpaces * level) + "#-" + display(node) + " (cycle)")) + else if (visited contains node) + // `prefix` is always "+-" here in practice (root can't re-enter), + // but mirror the level-0 form for symmetry. + Vector(limitLine((twoSpaces * level) + prefix + display(node) + " (*)")) else { - val line = limitLine((twoSpaces * level) + (if (level == 0) "" else "+-") + display(node)) + visited += node + val line = limitLine((twoSpaces * level) + prefix + display(node)) val cs = Vector(children(node)*) val childLines = cs map { toAsciiLines(_, level + 1, parents + node) @@ -116,6 +125,7 @@ object Graph { } line +: withBar } + } toAsciiLines(top, 0, Set.empty).mkString("\n") } diff --git a/main/src/main/scala/sbt/internal/graph/rendering/TreeView.scala b/main/src/main/scala/sbt/internal/graph/rendering/TreeView.scala index 9a9f58f03..eed759139 100644 --- a/main/src/main/scala/sbt/internal/graph/rendering/TreeView.scala +++ b/main/src/main/scala/sbt/internal/graph/rendering/TreeView.scala @@ -17,12 +17,12 @@ import sjsonnew.support.scalajson.unsafe.{ CompactPrinter, Converter } import java.io.{ File, FileOutputStream, InputStream, OutputStream } import java.net.URI +import scala.collection.mutable import scala.util.Using object TreeView { def createJson(graph: ModuleGraph): String = { - val moduleModels = graph.roots - .map(module => processSubtree(graph, module)) + val moduleModels = graph.roots.map(module => processSubtree(graph, module)) val js = moduleModels.map(Converter.toJsonUnsafe(_)) js.map(CompactPrinter).mkString("[", ",", "]") } @@ -45,26 +45,41 @@ object TreeView { graph: ModuleGraph, module: Module, parents: Set[GraphModuleId] = Set() + ): ModuleModel = + processSubtreeImpl(graph, module, parents, mutable.Set.empty[GraphModuleId]) + + // `visited` is owned by the caller; do not share it across renders. + private def processSubtreeImpl( + graph: ModuleGraph, + module: Module, + parents: Set[GraphModuleId], + visited: mutable.Set[GraphModuleId] ): ModuleModel = { val cycle = parents.contains(module.id) - val dependencies = if (cycle) List() else graph.dependencyMap.getOrElse(module.id, List()) + val duplicate = !cycle && visited.contains(module.id) + val dependencies = + if (cycle || duplicate) List() + else { + visited += module.id + graph.dependencyMap.getOrElse(module.id, List()) + } val children = dependencies - .map(dependency => processSubtree(graph, dependency, parents + module.id)) + .map(dependency => processSubtreeImpl(graph, dependency, parents + module.id, visited)) .toVector - moduleAsModuleAgain(module, cycle, children) + ModuleModel(displayText(module, cycle, duplicate), children) } - private def moduleAsModuleAgain( - module: Module, - isCycle: Boolean, - children: Vector[ModuleModel] - ): ModuleModel = { - val eviction = module.evictedByVersion.map(version => s" (evicted by $version)").getOrElse("") - val cycle = if (isCycle) " (cycle)" else "" - val error = module.error.map(err => s" (errors: $err)").getOrElse("") - val text = module.id.idString + eviction + error + cycle - ModuleModel(text, children) + // Suffix order is pinned by TreeViewTest "concatenate marker suffixes + // in a stable order"; change here means change the test. + private def displayText(module: Module, isCycle: Boolean, isDuplicate: Boolean): String = { + val suffixes = Vector( + module.evictedByVersion.map(v => s" (evicted by $v)"), + module.error.map(err => s" (errors: $err)"), + if (isCycle) Some(" (cycle)") else None, + if (isDuplicate) Some(" (*)") else None, + ).flatten + module.id.idString + suffixes.mkString } def saveResource(resourcePath: String, to: File): Unit = { diff --git a/main/src/test/scala/sbt/internal/graph/rendering/AsciiTreeTest.scala b/main/src/test/scala/sbt/internal/graph/rendering/AsciiTreeTest.scala new file mode 100644 index 000000000..59acf6944 --- /dev/null +++ b/main/src/test/scala/sbt/internal/graph/rendering/AsciiTreeTest.scala @@ -0,0 +1,78 @@ +/* + * sbt + * Copyright 2023, Scala center + * Copyright 2011 - 2022, Lightbend, Inc. + * Copyright 2008 - 2010, Mark Harrah + * Licensed under Apache License 2.0 (see LICENSE) + */ + +package sbt.internal.graph.rendering + +import sbt.internal.Graph +import verify.BasicTestSuite + +object AsciiTreeTest extends BasicTestSuite: + + private def render(adj: Map[Int, Seq[Int]], root: Int, width: Int = 80): String = + Graph.toAscii[Int](root, n => adj.getOrElse(n, Nil), _.toString, width) + + test("strict tree renders without markers") { + val adj = Map(0 -> Seq(1, 2), 1 -> Seq(3)) + val out = render(adj, 0) + assert(!out.contains("(*)"), s"unexpected (*):\n$out") + assert(!out.contains("(cycle)"), s"unexpected (cycle):\n$out") + assert(out.contains("3"), s"node 3 missing:\n$out") + } + + test("diamond DAG collapses the duplicate subtree to a single (*) line") { + // 0 + // / \ + // 1 2 + // \ / + // 3 + val adj = Map(0 -> Seq(1, 2), 1 -> Seq(3), 2 -> Seq(3)) + val out = render(adj, 0) + assert(out.contains("3 (*)"), s"missing collapsed `3 (*)`:\n$out") + val nonDup = out.linesIterator.filter(l => l.contains("3") && !l.contains("(*)")).toList + assert(nonDup.size == 1, s"node 3 must render exactly once in non-collapsed form: $nonDup") + } + + test("cycle uses (cycle), not (*)") { + val adj = Map(0 -> Seq(1), 1 -> Seq(0)) + val out = render(adj, 0) + assert(out.contains("(cycle)"), s"missing (cycle):\n$out") + assert(!out.contains("(*)"), s"unexpected (*):\n$out") + } + + test("canonical occurrence is the first-visited in DFS order") { + // top -> [a, b]; a -> [b]. DFS visits `a` first and expands `b` + // under it; `b` as `top`'s direct second child must collapse. + val adj = Map(0 -> Seq(1, 2), 1 -> Seq(2)) + val out = render(adj, 0) + val lines = out.linesIterator.toVector + val firstTwo = lines.indexWhere(l => l.contains("2") && !l.contains("(*)")) + val collapsedTwo = lines.indexWhere(l => l.contains("2 (*)")) + assert(firstTwo >= 0, s"non-duplicate `2` missing:\n$out") + assert(collapsedTwo >= 0, s"collapsed `2 (*)` missing:\n$out") + assert( + firstTwo < collapsedTwo, + s"non-duplicate `2` must render before `(*)` copy (got $firstTwo < $collapsedTwo):\n$out" + ) + } + + test("exponential DAG renders linear-sized output") { + // 12 nodes; each i depends on every j > i. Without dedup the + // output is O(2^11) lines. + val n = 12 + val adj: Map[Int, Seq[Int]] = + (0 until n).map(i => i -> ((i + 1) until n).toVector).toMap + val out = render(adj, 0, width = 200) + val lineCount = out.linesIterator.size + assert(lineCount < 200, s"DAG render exploded to $lineCount lines") + (0 until n).foreach { i => + val nonDup = out.linesIterator.exists(l => l.contains(s"$i") && !l.contains("(*)")) + assert(nonDup, s"node $i never rendered in non-collapsed form") + } + } + +end AsciiTreeTest diff --git a/main/src/test/scala/sbt/internal/graph/rendering/TreeViewTest.scala b/main/src/test/scala/sbt/internal/graph/rendering/TreeViewTest.scala index 705dd9dfc..c757d4265 100644 --- a/main/src/test/scala/sbt/internal/graph/rendering/TreeViewTest.scala +++ b/main/src/test/scala/sbt/internal/graph/rendering/TreeViewTest.scala @@ -11,7 +11,7 @@ package sbt.internal.graph.rendering import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers import sbt.internal.graph.rendering.TreeView.createJson -import sbt.internal.graph.{ GraphModuleId, Module, ModuleGraph, ModuleModel } +import sbt.internal.graph.{ Edge, GraphModuleId, Module, ModuleGraph, ModuleModel } class TreeViewTest extends AnyFlatSpec with Matchers { val modA = GraphModuleId("orgA", "nameA", "1.0") @@ -51,4 +51,120 @@ class TreeViewTest extends AnyFlatSpec with Matchers { ) assert(TreeView.processSubtree(graph, Module(modC), Set()) == expected) } + + // -- sbt/sbt#6886: DAG dedup -- + + it should "collapse a duplicate subtree to a leaf marked with (*)" in { + // a -> b -> c + // a -> c (c appears under two paths) + val a = GraphModuleId("o", "a", "1") + val b = GraphModuleId("o", "b", "1") + val c = GraphModuleId("o", "c", "1") + val d = GraphModuleId("o", "d", "1") // grandchild of c -- proves c's subtree only + // materializes once + val g = ModuleGraph( + nodes = Seq(Module(a), Module(b), Module(c), Module(d)), + edges = Seq(a -> b, a -> c, b -> c, c -> d) + ) + + val tree = TreeView.processSubtree(g, Module(a)) + + val expected = ModuleModel( + "o:a:1", + Vector( + ModuleModel( + "o:b:1", + Vector( + ModuleModel("o:c:1", Vector(ModuleModel("o:d:1", Vector()))) + ) + ), + // second occurrence collapsed, no children + ModuleModel("o:c:1 (*)", Vector()) + ) + ) + assert(tree == expected, s"got: $tree") + } + + it should "render output linear in DAG node count for an exponential graph" in { + // 8 nodes, each pointing to all later ones. Without dedup the JSON + // would be ~2^7 = 128 inner ModuleModels; with dedup it should be + // strictly bounded by the DAG node count (8) plus the duplicate + // collapsed leaves. + val nodes = ('a' to 'h').map(c => GraphModuleId("o", c.toString, "1")).toVector + val edges: Seq[Edge] = for + i <- nodes.indices + j <- (i + 1) until nodes.size + yield nodes(i) -> nodes(j) + val g = ModuleGraph(nodes = nodes.map(Module(_)), edges = edges) + + // Helper: count *unique* texts emitted (each module rendered once + // fully) and total ModuleModel count (with collapsed duplicates). + def walk(m: ModuleModel): (Set[String], Int) = + m.children.foldLeft((Set(m.text), 1)) { case ((seen, n), child) => + val (s, k) = walk(child) + (seen ++ s, n + k) + } + val (texts, total) = walk(TreeView.processSubtree(g, Module(nodes.head))) + + // Every module appears as a non-duplicate text exactly once. + nodes.foreach(n => assert(texts.contains(n.idString), s"missing $n in texts: $texts")) + // Without dedup, total would be 2^7 = 128. With dedup, total is the + // sum 1 + 7 + 6 + 5 + 4 + 3 + 2 + 1 = 29 (node `a`'s full subtree + // plus a duplicate leaf for every other path). + assert(total < 50, s"DAG rendering exploded: $total nodes") + } + + it should "concatenate marker suffixes in a stable order (eviction, then (*))" in { + // A module that's both evicted *and* shows up twice in the DAG. + // Pins the suffix order so any future renderer refactor can't + // silently change `(evicted by 2.0) (*)` to `(*) (evicted by 2.0)` + // for callers parsing these lines. + val a = GraphModuleId("o", "a", "1") + val b = GraphModuleId("o", "b", "1") + val c = GraphModuleId("o", "c", "1") + val evicted = Module(c, evictedByVersion = Some("2.0")) + val g = ModuleGraph( + nodes = Seq(Module(a), Module(b), evicted), + edges = Seq(a -> b, a -> c, b -> c) + ) + val tree = TreeView.processSubtree(g, Module(a)) + // First occurrence (under b): "o:c:1 (evicted by 2.0)"; collapsed + // occurrence (direct child of a): "o:c:1 (evicted by 2.0) (*)". + val texts = collectTexts(tree) + assert( + texts.contains("o:c:1 (evicted by 2.0)"), + s"missing first-occurrence text; got: $texts" + ) + assert( + texts.contains("o:c:1 (evicted by 2.0) (*)"), + s"missing collapsed-occurrence text; got: $texts" + ) + } + + private def collectTexts(m: ModuleModel): Set[String] = + m.children.foldLeft(Set(m.text))((s, c) => s ++ collectTexts(c)) + + it should "report a cycle differently than a duplicate" in { + // a -> b -> a (cycle) + // a -> c (no cycle, no duplicate) + val a = GraphModuleId("o", "a", "1") + val b = GraphModuleId("o", "b", "1") + val c = GraphModuleId("o", "c", "1") + val g = ModuleGraph( + nodes = Seq(Module(a), Module(b), Module(c)), + edges = Seq(a -> b, b -> a, a -> c) + ) + val tree = TreeView.processSubtree(g, Module(a)) + val expected = ModuleModel( + "o:a:1", + Vector( + ModuleModel( + "o:b:1", + Vector(ModuleModel("o:a:1 (cycle)", Vector())) + ), + ModuleModel("o:c:1", Vector()) + ) + ) + assert(tree == expected, s"got: $tree") + } } diff --git a/notes/2.0.0/dependency-tree-dedup.md b/notes/2.0.0/dependency-tree-dedup.md new file mode 100644 index 000000000..acdc0f1de --- /dev/null +++ b/notes/2.0.0/dependency-tree-dedup.md @@ -0,0 +1,66 @@ +## Dependency tree: duplicate-subtree collapse + +`dependencyTree`, `dependencyBrowseTree`, and `inspect tree` now collapse +duplicate subtrees in a DAG to a single line marked `(*)`, matching the +convention used by Maven's `dependency:tree`. The first occurrence is +rendered in full; subsequent occurrences appear as `+- (*)`. + +This fixes [#6886][i6886]: rendering a deep diamond DAG no longer +produces `O(M^N)` output (and the OOMs that came with it). + +### Output change + +Before: + +``` +o:root_2.13:0.1 + +-o:subA_2.13:0.1 [S] + | +-o:common_2.13:0.1 [S] + | | +-org.scala-lang:scala-library:2.13.16 [S] + | +-org.scala-lang:scala-library:2.13.16 [S] + +-o:subB_2.13:0.1 [S] + | +-o:common_2.13:0.1 [S] # full subtree again + | | +-org.scala-lang:scala-library:2.13.16 [S] + | +-org.scala-lang:scala-library:2.13.16 [S] + +-org.scala-lang:scala-library:2.13.16 [S] +``` + +After: + +``` +o:root_2.13:0.1 + +-o:subA_2.13:0.1 [S] + | +-o:common_2.13:0.1 [S] + | | +-org.scala-lang:scala-library:2.13.16 [S] + | +-org.scala-lang:scala-library:2.13.16 [S] + +-o:subB_2.13:0.1 [S] + | +-o:common_2.13:0.1 [S] (*) # collapsed + | +-org.scala-lang:scala-library:2.13.16 [S] (*) + +-org.scala-lang:scala-library:2.13.16 [S] (*) +``` + +### Affected surfaces + +- `dependencyTree`: ASCII output (also `dependencyTreeList`, + `dependencyTreeStats`). +- `dependencyBrowseTree`: JSON / HTML view. +- `inspect tree`: the setting-graph renderer is the same code path, so + the `(*)` marker shows up there too. Most users don't think of + `inspect tree` as "the dependency tree" -- this note is the + heads-up. + +### Contract for tooling consumers + +A line whose entry ends with `(*)` is a reference to the canonical +(first-rendered) occurrence of that node within the same render. +Tools parsing `dependencyTree` / `dependencyBrowseTree` output should +treat ` (*)` as a back-pointer rather than a distinct dependency. + +### Scope + +Dedup is currently within a single root's subtree. Cross-root dedup +(when a `ModuleGraph` has multiple roots that share a transitive +closure) is tracked separately as [#9227][i9227]. + +[i6886]: https://github.com/sbt/sbt/issues/6886 +[i9227]: https://github.com/sbt/sbt/issues/9227 diff --git a/sbt-app/src/sbt-test/dependency-graph/asciiGraphWidth/when-is-20/expected.txt b/sbt-app/src/sbt-test/dependency-graph/asciiGraphWidth/when-is-20/expected.txt index bad2b603a..6282e68f0 100644 --- a/sbt-app/src/sbt-test/dependency-graph/asciiGraphWidth/when-is-20/expected.txt +++ b/sbt-app/src/sbt-test/dependency-graph/asciiGraphWidth/when-is-20/expected.txt @@ -7,7 +7,4 @@ whenis20:whenis20_.. | +-org.typeleve.. +-org.typele.. - +-org.type.. - +-org.ty.. - +-org.ty.. - \ No newline at end of file + \ No newline at end of file diff --git a/sbt-app/src/sbt-test/dependency-graph/asciiGraphWidth/when-is-default/expected.txt b/sbt-app/src/sbt-test/dependency-graph/asciiGraphWidth/when-is-default/expected.txt index 284feffc8..bee005f1b 100644 --- a/sbt-app/src/sbt-test/dependency-graph/asciiGraphWidth/when-is-default/expected.txt +++ b/sbt-app/src/sbt-test/dependency-graph/asciiGraphWidth/when-is-default/expected.txt @@ -6,8 +6,5 @@ whenisdefault:whenisdefault_2.13:0.1.0-SNAPSHOT [S] | +-org.typelevel:simulacrum-scalafix-annotations_2.13:0.5.4 [S] | +-org.typelevel:cats-effect-std_2.13:3.1.0 [S] - +-org.typelevel:cats-effect-kernel_2.13:3.1.0 [S] - +-org.typelevel:cats-core_2.13:2.6.0 [S] - +-org.typelevel:cats-kernel_2.13:2.6.0 [S] - +-org.typelevel:simulacrum-scalafix-annotations_2.13:0.5.4 [S] - \ No newline at end of file + +-org.typelevel:cats-effect-kernel_2.13:3.1.0 [S] (*) + \ No newline at end of file diff --git a/sbt-app/src/sbt-test/dependency-graph/dag-dedup/build.sbt b/sbt-app/src/sbt-test/dependency-graph/dag-dedup/build.sbt new file mode 100644 index 000000000..e3951b040 --- /dev/null +++ b/sbt-app/src/sbt-test/dependency-graph/dag-dedup/build.sbt @@ -0,0 +1,49 @@ +// Regression test for sbt/sbt#6886. Local multi-project diamond: the +// root depends on subA and subB, both of which depend on common. So +// `dependencyTree` for the root must render `common` fully on its +// first occurrence and collapse to `(*)` on the second. Local +// fixture (no external maven dependencies) so this test stays stable +// across Coursier behavior changes and external library churn. +ThisBuild / organization := "org.example" +ThisBuild / version := "0.1" +ThisBuild / scalaVersion := "2.13.16" + +lazy val common = (project in file("common")) + .settings(name := "common") + +lazy val subA = (project in file("subA")) + .settings(name := "subA") + .dependsOn(common) + +lazy val subB = (project in file("subB")) + .settings(name := "subB") + .dependsOn(common) + +lazy val root = (project in file(".")) + .settings(name := "root") + .dependsOn(subA, subB) + .aggregate(common, subA, subB) + +// Grep-based check, not full diff: Coursier may legitimately reorder +// siblings, and pinning exact line offsets makes the test brittle. +TaskKey[Unit]("check") := { + val tree = IO.read(file("target/tree.txt")) + // Tie the assertion to the specific diamond this fixture exercises: + // `common` must appear on at least one line that also carries the + // `(*)` marker. A regression where `common` specifically fails to + // collapse would otherwise slip through as long as scala-library + // (also reachable through both subA and subB) still collapsed. + val collapsedCommon = tree.linesIterator.exists(l => + l.contains("common") && l.contains("(*)") + ) + assert( + collapsedCommon, + s"expected `common` to appear with a `(*)` marker (diamond apex collapsed):\n$tree" + ) + // Sanity: cycle marker must NOT appear -- collapse is for duplicate + // (DAG re-traversal), not cycle (graph back-edge). + assert( + !tree.contains("(cycle)"), + s"unexpected (cycle) marker in dependencyTree output:\n$tree" + ) +} diff --git a/sbt-app/src/sbt-test/dependency-graph/dag-dedup/common/.gitkeep b/sbt-app/src/sbt-test/dependency-graph/dag-dedup/common/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/sbt-app/src/sbt-test/dependency-graph/dag-dedup/subA/.gitkeep b/sbt-app/src/sbt-test/dependency-graph/dag-dedup/subA/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/sbt-app/src/sbt-test/dependency-graph/dag-dedup/subB/.gitkeep b/sbt-app/src/sbt-test/dependency-graph/dag-dedup/subB/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/sbt-app/src/sbt-test/dependency-graph/dag-dedup/test b/sbt-app/src/sbt-test/dependency-graph/dag-dedup/test new file mode 100644 index 000000000..c5522ebbe --- /dev/null +++ b/sbt-app/src/sbt-test/dependency-graph/dag-dedup/test @@ -0,0 +1,5 @@ +# dependencyTree on the aggregating root prints the diamond shape: +# root -> subA, subB; both depend on common. The first occurrence of +# `common` is rendered fully; the second collapses to ` (*)`. +> dependencyTree --out target/tree.txt +> check diff --git a/sbt-app/src/sbt-test/dependency-graph/whatDependsOn-without-previous-initialization/build.sbt b/sbt-app/src/sbt-test/dependency-graph/whatDependsOn-without-previous-initialization/build.sbt index efe00ef52..c61342fd6 100644 --- a/sbt-app/src/sbt-test/dependency-graph/whatDependsOn-without-previous-initialization/build.sbt +++ b/sbt-app/src/sbt-test/dependency-graph/whatDependsOn-without-previous-initialization/build.sbt @@ -27,8 +27,7 @@ check := { "| +-org.typelevel:cats-effect_2.13:3.1.0 [S]", "| +-whatdependson:whatdependson_2.13:0.1.0-SNAPSHOT [S]", "|", - "+-org.typelevel:cats-effect_2.13:3.1.0 [S]", - "+-whatdependson:whatdependson_2.13:0.1.0-SNAPSHOT [S]" + "+-org.typelevel:cats-effect_2.13:3.1.0 [S] (*)" ).mkString("\n") } @@ -46,8 +45,7 @@ check := { "| +-org.typelevel:cats-effect_2.13:3.1.0 [S]", "| +-whatdependson:whatdependson_2.13:0.1.0-SNAPSHOT [S]", "|", - "+-org.typelevel:cats-effect_2.13:3.1.0 [S]", - "+-whatdependson:whatdependson_2.13:0.1.0-SNAPSHOT [S]" + "+-org.typelevel:cats-effect_2.13:3.1.0 [S] (*)" ).mkString("\n") checkOutput(withoutVersion.trim, expectedGraphWithoutVersion.trim) diff --git a/sbt-app/src/sbt-test/dependency-graph/whatDependsOn/build.sbt b/sbt-app/src/sbt-test/dependency-graph/whatDependsOn/build.sbt index ddda3796f..635095602 100644 --- a/sbt-app/src/sbt-test/dependency-graph/whatDependsOn/build.sbt +++ b/sbt-app/src/sbt-test/dependency-graph/whatDependsOn/build.sbt @@ -28,8 +28,7 @@ check := { "| +-org.typelevel:cats-effect_2.13:3.1.0 [S]", "| +-whatdependson:whatdependson_2.13:0.1.0-SNAPSHOT [S]", "|", - "+-org.typelevel:cats-effect_2.13:3.1.0 [S]", - "+-whatdependson:whatdependson_2.13:0.1.0-SNAPSHOT [S]" + "+-org.typelevel:cats-effect_2.13:3.1.0 [S] (*)" ).mkString("\n") checkOutput(withVersion.trim, expectedGraphWithVersion) @@ -46,8 +45,7 @@ check := { "| +-org.typelevel:cats-effect_2.13:3.1.0 [S]", "| +-whatdependson:whatdependson_2.13:0.1.0-SNAPSHOT [S]", "|", - "+-org.typelevel:cats-effect_2.13:3.1.0 [S]", - "+-whatdependson:whatdependson_2.13:0.1.0-SNAPSHOT [S]" + "+-org.typelevel:cats-effect_2.13:3.1.0 [S] (*)" ).mkString("\n") checkOutput(withoutVersion.trim, expectedGraphWithoutVersion.trim)