diff --git a/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/visitor/CycleAwareDependencyGraphDumper.java b/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/visitor/CycleAwareDependencyGraphDumper.java
new file mode 100644
index 000000000..d429ed80f
--- /dev/null
+++ b/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/visitor/CycleAwareDependencyGraphDumper.java
@@ -0,0 +1,240 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.eclipse.aether.util.graph.visitor;
+
+import java.util.ArrayDeque;
+import java.util.Collection;
+import java.util.Deque;
+import java.util.Iterator;
+import java.util.List;
+import java.util.function.Consumer;
+import java.util.function.Function;
+
+import org.eclipse.aether.artifact.Artifact;
+import org.eclipse.aether.graph.DependencyNode;
+import org.eclipse.aether.graph.DependencyVisitor;
+import org.eclipse.aether.util.artifact.ArtifactIdUtils;
+
+import static java.util.Objects.requireNonNull;
+
+/**
+ * A dependency visitor that visualizes cycles in dependency graphs while preventing StackOverflow errors.
+ * This visitor wraps a {@link DependencyGraphDumper} and adds cycle detection and visualization capabilities.
+ *
+ * When a cycle is detected (a node with the same versionless artifact ID appears again in the current path),
+ * it is displayed with a reference notation like {@code ^N} where N is the index of the node in the path that
+ * it cycles back to. The visitor then stops traversing children of the cycle node to prevent infinite recursion.
+ *
+ *
+ * This visitor is particularly useful for visualizing dependency graphs in FULL verbosity mode where cycles
+ * are preserved in the graph structure.
+ *
+ *
+ * @since 2.0.0
+ */
+public class CycleAwareDependencyGraphDumper implements DependencyVisitor {
+
+ private final Consumer consumer;
+ private final DependencyGraphDumper dumper;
+ private final Deque currentPath;
+ private final Stack isCycleStack;
+
+ /**
+ * Creates a new cycle-aware dependency graph dumper with the specified consumer.
+ *
+ * @param consumer the string consumer, must not be {@code null}
+ */
+ public CycleAwareDependencyGraphDumper(Consumer consumer) {
+ this.consumer = requireNonNull(consumer, "consumer cannot be null");
+ this.dumper = new DependencyGraphDumper(consumer);
+ this.currentPath = new ArrayDeque<>();
+ this.isCycleStack = new Stack<>();
+ }
+
+ /**
+ * Creates a new cycle-aware dependency graph dumper with the specified consumer and decorators.
+ *
+ * @param consumer the string consumer, must not be {@code null}
+ * @param decorators the decorators to apply, must not be {@code null}
+ */
+ public CycleAwareDependencyGraphDumper(
+ Consumer consumer, Collection> decorators) {
+ this.consumer = requireNonNull(consumer, "consumer cannot be null");
+ this.dumper = new DependencyGraphDumper(consumer, decorators);
+ this.currentPath = new ArrayDeque<>();
+ this.isCycleStack = new Stack<>();
+ }
+
+ @Override
+ public boolean visitEnter(DependencyNode node) {
+ requireNonNull(node, "node cannot be null");
+
+ // Check for cycle BEFORE adding current node to path
+ int cycleIndex = findCycleInPath(node);
+ boolean isCycle = cycleIndex >= 0;
+
+ // Add node to path after checking (for formatting purposes)
+ currentPath.push(node);
+ isCycleStack.push(isCycle);
+
+ if (isCycle) {
+ // Format the cycle line with reference notation
+ // Use custom formatting for cycle nodes since they might be references
+ String indentation = formatCycleIndentation(currentPath);
+ String nodeStr = dumper.formatNode(currentPath);
+ String line = indentation + nodeStr + " ^" + cycleIndex;
+ consumer.accept(line);
+ return false; // Stop traversing children to prevent infinite recursion
+ }
+
+ // Delegate to the wrapped dumper for normal nodes
+ return dumper.visitEnter(node);
+ }
+
+ @Override
+ public boolean visitLeave(DependencyNode node) {
+ if (!currentPath.isEmpty() && currentPath.peek() == node) {
+ currentPath.pop();
+ }
+
+ Boolean isCycle = isCycleStack.pop();
+
+ // Only call dumper.visitLeave if we called dumper.visitEnter
+ // (i.e., if it's not a cycle node)
+ if (isCycle) {
+ return true;
+ }
+
+ return dumper.visitLeave(node);
+ }
+
+ /**
+ * Finds if the given node creates a cycle in the current path by checking if a node with the same
+ * versionless artifact ID already exists in the path.
+ *
+ * @param node the node to check, must not be {@code null}
+ * @return the index of the node in the current path that has the same versionless ID, or {@code -1} if no cycle
+ */
+ private int findCycleInPath(DependencyNode node) {
+ Artifact currentArtifact = node.getArtifact();
+ if (currentArtifact == null) {
+ return -1;
+ }
+
+ int index = 0;
+ for (DependencyNode pathNode : currentPath) {
+ Artifact pathArtifact = pathNode.getArtifact();
+ if (pathArtifact != null
+ && ArtifactIdUtils.equalsVersionlessId(currentArtifact, pathArtifact)) {
+ return index; // Return the index of the node in the path (0-based, root is 0)
+ }
+ index++;
+ }
+ return -1; // No cycle found
+ }
+
+ /**
+ * Formats the indentation for a cycle node. This is needed because cycle nodes
+ * might not be in the parent's children list (they are references), so we need
+ * to manually calculate the indentation based on the path structure.
+ *
+ * This method uses the same logic as {@link DependencyGraphDumper#formatIndentation(Deque)},
+ * but additionally handles cycle nodes that might be references by using artifact ID
+ * comparison when identity comparison fails.
+ *
+ *
+ * @param path the current path including the cycle node
+ * @return the indentation string for the cycle node
+ */
+ private String formatCycleIndentation(Deque path) {
+ if (path.size() < 2) {
+ return "";
+ }
+
+ StringBuilder buffer = new StringBuilder(128);
+ Iterator iter = path.descendingIterator();
+ DependencyNode parent = iter.hasNext() ? iter.next() : null;
+ DependencyNode child = iter.hasNext() ? iter.next() : null;
+ DependencyNode cycleNode = path.peekFirst(); // The cycle node is at the top
+
+ while (parent != null && child != null) {
+ boolean isLast = isLastChild(parent, child);
+ boolean end = child == cycleNode;
+
+ String indent = formatIndentString(isLast, end);
+ buffer.append(indent);
+
+ parent = child;
+ child = iter.hasNext() ? iter.next() : null;
+ }
+
+ return buffer.toString();
+ }
+
+ /**
+ * Determines if the given child is the last child of its parent.
+ * For cycle nodes that might be references, uses artifact ID comparison
+ * when identity comparison fails.
+ *
+ * @param parent the parent node
+ * @param child the child node to check
+ * @return {@code true} if the child is the last child, {@code false} otherwise
+ */
+ private boolean isLastChild(DependencyNode parent, DependencyNode child) {
+ List children = parent.getChildren();
+ if (children.isEmpty()) {
+ return false;
+ }
+
+ DependencyNode lastChild = children.get(children.size() - 1);
+
+ // Try identity comparison first (same as DependencyGraphDumper)
+ if (lastChild == child) {
+ return true;
+ }
+
+ // If identity fails, try artifact ID comparison (for cycle nodes that are references)
+ Artifact childArtifact = child.getArtifact();
+ if (childArtifact != null) {
+ Artifact lastArtifact = lastChild.getArtifact();
+ if (lastArtifact != null
+ && ArtifactIdUtils.equalsVersionlessId(childArtifact, lastArtifact)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Formats the indentation string for a single level.
+ *
+ * @param isLast whether this is the last child
+ * @param isEnd whether this is the final node (cycle node)
+ * @return the indentation string ("+- ", "\\- ", "| ", or " ")
+ */
+ private String formatIndentString(boolean isLast, boolean isEnd) {
+ if (isEnd) {
+ return isLast ? "\\- " : "+- ";
+ } else {
+ return isLast ? " " : "| ";
+ }
+ }
+}
+
diff --git a/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/visitor/CycleAwareDependencyGraphDumperTest.java b/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/visitor/CycleAwareDependencyGraphDumperTest.java
new file mode 100644
index 000000000..f9f1b2e0b
--- /dev/null
+++ b/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/visitor/CycleAwareDependencyGraphDumperTest.java
@@ -0,0 +1,103 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.eclipse.aether.util.graph.visitor;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.eclipse.aether.graph.DependencyNode;
+import org.eclipse.aether.internal.test.util.DependencyGraphParser;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+public class CycleAwareDependencyGraphDumperTest {
+
+ private DependencyNode parse(String resource) throws Exception {
+ return new DependencyGraphParser("visitor/ordered-list/").parseResource(resource);
+ }
+
+ @Test
+ void dumpSimple() throws Exception {
+ DependencyNode root = parse("simple.txt");
+
+ List cycleAwareOutput = new ArrayList<>();
+ root.accept(new CycleAwareDependencyGraphDumper(cycleAwareOutput::add));
+
+ List standardOutput = new ArrayList<>();
+ root.accept(new DependencyGraphDumper(standardOutput::add));
+
+ // For graphs without cycles, output should be identical
+ assertEquals(standardOutput.size(), cycleAwareOutput.size());
+ for (int i = 0; i < standardOutput.size(); i++) {
+ assertEquals(standardOutput.get(i), cycleAwareOutput.get(i));
+ }
+ }
+
+ @Test
+ void dumpCycles() throws Exception {
+ DependencyNode root = parse("cycles.txt");
+
+ List output = new ArrayList<>();
+ root.accept(new CycleAwareDependencyGraphDumper(output::add));
+
+ assertFalse(output.isEmpty());
+ assertTrue(output.stream().anyMatch(line -> line.contains("^")));
+ assertTrue(output.stream().anyMatch(line -> line.matches(".*\\^\\d+.*")));
+ }
+
+ @Test
+ void dumpCyclesNoStackOverflow() throws Exception {
+ DependencyNode root = parse("cycles.txt");
+ List output = new ArrayList<>();
+ assertDoesNotThrow(() -> root.accept(new CycleAwareDependencyGraphDumper(output::add)));
+ assertFalse(output.isEmpty());
+ }
+
+ @Test
+ void cycleReferencePointsToCorrectIndex() throws Exception {
+ DependencyNode root = parse("cycles.txt");
+ List output = new ArrayList<>();
+ root.accept(new CycleAwareDependencyGraphDumper(output::add));
+
+ String cycleLine = output.stream()
+ .filter(line -> line.contains("^"))
+ .findFirst()
+ .orElse(null);
+
+ assertNotNull(cycleLine);
+ int cycleIndex = extractCycleIndex(cycleLine);
+ assertTrue(cycleIndex >= 0);
+ assertTrue(cycleIndex < output.size());
+ }
+
+ private int extractCycleIndex(String line) {
+ int caretIndex = line.indexOf('^');
+ if (caretIndex < 0) {
+ return -1;
+ }
+ String afterCaret = line.substring(caretIndex + 1).trim();
+ try {
+ return Integer.parseInt(afterCaret.split("\\s")[0]);
+ } catch (NumberFormatException e) {
+ return -1;
+ }
+ }
+}
+