You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maven.apache.org by sl...@apache.org on 2026/10/13 22:14:59 UTC

(maven-dependency-plugin) 01/01: [MDEP-962] Report omitted reasons in verbose JSON tree output

This is an automated email from the ASF dual-hosted git repository.

slachiewicz pushed a commit to branch agent/mdep-962-json-verbose
in repository https://gitbox.apache.org/repos/asf/maven-dependency-plugin.git

commit 57ea05673c49f3e83420eba9eb62d7d468a14e38
Author: Sylwester Lachiewicz <sl...@apache.org>
AuthorDate: Tue Oct 13 23:27:34 2026 +0200

    [MDEP-962] Report omitted reasons in verbose JSON tree output
---
 .../projects/tree-verbose-json/invoker.properties  |  22 ++++
 src/it/projects/tree-verbose-json/plain.properties |  19 ++++
 src/it/projects/tree-verbose-json/pom.xml          |  52 ++++++++++
 .../projects/tree-verbose-json/verbose.properties  |  20 ++++
 src/it/projects/tree-verbose-json/verify.groovy    |  53 ++++++++++
 .../plugins/dependency/tree/DependencyNode.java    |  92 +++++++++++++++++
 .../dependency/tree/DependencyTreeBuilder.java     |  40 ++++---
 .../dependency/tree/JsonDependencyNodeVisitor.java |  53 +++++++++-
 src/site/markdown/examples/tree-mojo.md            |   1 +
 .../tree/JsonDependencyNodeVisitorTest.java        | 115 +++++++++++++++++++++
 10 files changed, 449 insertions(+), 18 deletions(-)

diff --git a/src/it/projects/tree-verbose-json/invoker.properties b/src/it/projects/tree-verbose-json/invoker.properties
new file mode 100644
index 00000000..f7a40d29
--- /dev/null
+++ b/src/it/projects/tree-verbose-json/invoker.properties
@@ -0,0 +1,22 @@
+# 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.
+
+invoker.goals.1 = ${project.groupId}:${project.artifactId}:${project.version}:tree
+invoker.userPropertiesFile.1 = verbose.properties
+
+invoker.goals.2 = ${project.groupId}:${project.artifactId}:${project.version}:tree
+invoker.userPropertiesFile.2 = plain.properties
diff --git a/src/it/projects/tree-verbose-json/plain.properties b/src/it/projects/tree-verbose-json/plain.properties
new file mode 100644
index 00000000..4fbe19bb
--- /dev/null
+++ b/src/it/projects/tree-verbose-json/plain.properties
@@ -0,0 +1,19 @@
+# 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.
+
+outputType = json
+outputFile = target/plain.json
diff --git a/src/it/projects/tree-verbose-json/pom.xml b/src/it/projects/tree-verbose-json/pom.xml
new file mode 100644
index 00000000..7395ca17
--- /dev/null
+++ b/src/it/projects/tree-verbose-json/pom.xml
@@ -0,0 +1,52 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+  ~ 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.
+  -->
+
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+  <modelVersion>4.0.0</modelVersion>
+
+  <groupId>org.apache.maven.plugins.dependency.its</groupId>
+  <artifactId>tree-verbose-json</artifactId>
+  <version>1.0-SNAPSHOT</version>
+
+  <description>
+    dependency:tree with outputType=json and verbose=true reports why a node was omitted.
+  </description>
+
+  <dependencies>
+    <dependency>
+      <groupId>org.apache.maven.its.dependency</groupId>
+      <artifactId>scope-root</artifactId>
+      <version>1.0</version>
+    </dependency>
+    <dependency>
+      <groupId>org.apache.maven.its.dependency</groupId>
+      <artifactId>scope-test-root</artifactId>
+      <version>1.0</version>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.apache.maven.its.dependency</groupId>
+      <artifactId>provided-root</artifactId>
+      <version>1.0</version>
+    </dependency>
+  </dependencies>
+</project>
diff --git a/src/it/projects/tree-verbose-json/verbose.properties b/src/it/projects/tree-verbose-json/verbose.properties
new file mode 100644
index 00000000..2e10bb33
--- /dev/null
+++ b/src/it/projects/tree-verbose-json/verbose.properties
@@ -0,0 +1,20 @@
+# 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.
+
+outputType = json
+verbose = true
+outputFile = target/verbose.json
diff --git a/src/it/projects/tree-verbose-json/verify.groovy b/src/it/projects/tree-verbose-json/verify.groovy
new file mode 100644
index 00000000..65b759a2
--- /dev/null
+++ b/src/it/projects/tree-verbose-json/verify.groovy
@@ -0,0 +1,53 @@
+/*
+ * 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.
+ */
+
+import groovy.json.JsonSlurper
+
+import static org.assertj.core.api.Assertions.assertThat
+
+def verbose = new JsonSlurper().parse(new File(basedir, "target/verbose.json"))
+
+// scope-root -> scope-leaf:1.0 is the node Maven keeps
+def kept = verbose.children[0].children[0]
+assertThat(kept.version).isEqualTo("1.0")
+assertThat(kept.included).isEqualTo(true)
+assertThat(kept).doesNotContainKey("omittedReason")
+
+// scope-test-root -> scope-leaf:2.0 loses to 1.0
+def omitted = verbose.children[1].children[0]
+assertThat(omitted.version).isEqualTo("2.0")
+assertThat(omitted.included).isEqualTo(false)
+assertThat(omitted.omittedReason).isEqualTo("conflict")
+assertThat(omitted.winnerVersion).isEqualTo("1.0")
+
+// provided-root -> scope-leaf:1.0 repeats the kept node
+def duplicate = verbose.children[2].children[0]
+assertThat(duplicate.version).isEqualTo("1.0")
+assertThat(duplicate.included).isEqualTo(false)
+assertThat(duplicate.omittedReason).isEqualTo("duplicate")
+assertThat(duplicate).doesNotContainKey("winnerVersion")
+
+// the direct dependencies are included
+assertThat(verbose.children*.included).containsOnly(true)
+
+// without verbose the JSON has none of the additional fields
+def plain = new File(basedir, "target/plain.json").text
+assertThat(plain).doesNotContain("included").doesNotContain("omittedReason").doesNotContain("winnerVersion")
+
+return true
diff --git a/src/main/java/org/apache/maven/plugins/dependency/tree/DependencyNode.java b/src/main/java/org/apache/maven/plugins/dependency/tree/DependencyNode.java
index 4fbaeca6..cae0a933 100644
--- a/src/main/java/org/apache/maven/plugins/dependency/tree/DependencyNode.java
+++ b/src/main/java/org/apache/maven/plugins/dependency/tree/DependencyNode.java
@@ -37,6 +37,8 @@ public class DependencyNode {
 
     private List<DependencyNode> children = Collections.emptyList();
 
+    private VerboseInfo verboseInfo;
+
     /**
      * @param parent the parent node, or <code>null</code> for the root
      * @param artifact the artifact of this node
@@ -64,6 +66,22 @@ public class DependencyNode {
         this.children = children;
     }
 
+    /**
+     * @return why the node is omitted and what was managed, or <code>null</code> if the tree was not built verbose
+     * @since 3.12.0
+     */
+    public VerboseInfo getVerboseInfo() {
+        return verboseInfo;
+    }
+
+    /**
+     * @param verboseInfo see {@link #getVerboseInfo()}
+     * @since 3.12.0
+     */
+    public void setVerboseInfo(VerboseInfo verboseInfo) {
+        this.verboseInfo = verboseInfo;
+    }
+
     /**
      * @return the label of this node
      */
@@ -104,6 +122,7 @@ public class DependencyNode {
         }
 
         DependencyNode copy = new DependencyNode(newParent, artifact, nodeString);
+        copy.setVerboseInfo(verboseInfo);
         List<DependencyNode> copiedChildren = new ArrayList<>(children.size());
         for (DependencyNode child : children) {
             DependencyNode copiedChild = child.filter(copy, keep, include);
@@ -119,4 +138,77 @@ public class DependencyNode {
         copy.setChildren(copiedChildren);
         return copy;
     }
+
+    /**
+     * What the verbose tree knows about a node besides its artifact: whether it is omitted and why, and the version and
+     * scope that dependency management or scope selection replaced.
+     *
+     * @since 3.12.0
+     */
+    public static final class VerboseInfo {
+        /**
+         * The node repeats an artifact of the same version that is kept elsewhere in the tree.
+         */
+        public static final String OMITTED_FOR_DUPLICATE = "duplicate";
+
+        /**
+         * The node loses to another version of the artifact.
+         */
+        public static final String OMITTED_FOR_CONFLICT = "conflict";
+
+        private final String omittedReason;
+
+        private final String winnerVersion;
+
+        private final String premanagedVersion;
+
+        private final String premanagedScope;
+
+        private final String scopeNotUpdatedTo;
+
+        /**
+         * @param omittedReason {@link #OMITTED_FOR_DUPLICATE}, {@link #OMITTED_FOR_CONFLICT} or <code>null</code> if
+         *            the node is included
+         * @param winnerVersion the version kept instead of this node, <code>null</code> if the node is included
+         * @param premanagedVersion the version before dependency management, or <code>null</code>
+         * @param premanagedScope the scope before dependency management, or <code>null</code>
+         * @param scopeNotUpdatedTo the scope that scope selection wanted but did not apply, or <code>null</code>
+         */
+        public VerboseInfo(
+                String omittedReason,
+                String winnerVersion,
+                String premanagedVersion,
+                String premanagedScope,
+                String scopeNotUpdatedTo) {
+            this.omittedReason = omittedReason;
+            this.winnerVersion = winnerVersion;
+            this.premanagedVersion = premanagedVersion;
+            this.premanagedScope = premanagedScope;
+            this.scopeNotUpdatedTo = scopeNotUpdatedTo;
+        }
+
+        public boolean isIncluded() {
+            return omittedReason == null;
+        }
+
+        public String getOmittedReason() {
+            return omittedReason;
+        }
+
+        public String getWinnerVersion() {
+            return winnerVersion;
+        }
+
+        public String getPremanagedVersion() {
+            return premanagedVersion;
+        }
+
+        public String getPremanagedScope() {
+            return premanagedScope;
+        }
+
+        public String getScopeNotUpdatedTo() {
+            return scopeNotUpdatedTo;
+        }
+    }
 }
diff --git a/src/main/java/org/apache/maven/plugins/dependency/tree/DependencyTreeBuilder.java b/src/main/java/org/apache/maven/plugins/dependency/tree/DependencyTreeBuilder.java
index 7f3ec0e4..ccd771fa 100644
--- a/src/main/java/org/apache/maven/plugins/dependency/tree/DependencyTreeBuilder.java
+++ b/src/main/java/org/apache/maven/plugins/dependency/tree/DependencyTreeBuilder.java
@@ -26,6 +26,7 @@ import org.apache.maven.RepositoryUtils;
 import org.apache.maven.artifact.Artifact;
 import org.apache.maven.artifact.resolver.filter.ArtifactFilter;
 import org.apache.maven.model.Dependency;
+import org.apache.maven.plugins.dependency.tree.DependencyNode.VerboseInfo;
 import org.apache.maven.project.DefaultDependencyResolutionRequest;
 import org.apache.maven.project.DependencyResolutionException;
 import org.apache.maven.project.DependencyResolutionRequest;
@@ -124,8 +125,10 @@ class DependencyTreeBuilder {
             Artifact artifact,
             ArtifactFilter filter,
             boolean verbose) {
+        VerboseInfo info = verbose ? verboseInfo(node, artifact) : null;
         DependencyNode current = new DependencyNode(
-                parent, artifact, verbose ? verboseNodeString(node, artifact) : nodeString(artifact));
+                parent, artifact, verbose ? verboseNodeString(info, artifact) : nodeString(artifact));
+        current.setVerboseInfo(info);
 
         List<DependencyNode> children = new ArrayList<>(node.getChildren().size());
         for (org.eclipse.aether.graph.DependencyNode child : node.getChildren()) {
@@ -151,18 +154,31 @@ class DependencyTreeBuilder {
         return artifact + (artifact.isOptional() ? " (optional)" : "");
     }
 
-    private static String verboseNodeString(org.eclipse.aether.graph.DependencyNode node, Artifact artifact) {
+    private static VerboseInfo verboseInfo(org.eclipse.aether.graph.DependencyNode node, Artifact artifact) {
         org.eclipse.aether.graph.DependencyNode winner =
                 (org.eclipse.aether.graph.DependencyNode) node.getData().get(ConflictResolver.NODE_DATA_WINNER);
         String winnerVersion = null;
         String ignoredScope = null;
+        String omittedReason = null;
         if (winner != null) {
             winnerVersion = winner.getArtifact().getBaseVersion();
+            omittedReason = winnerVersion.equals(artifact.getVersion())
+                    ? VerboseInfo.OMITTED_FOR_DUPLICATE
+                    : VerboseInfo.OMITTED_FOR_CONFLICT;
         } else {
             ignoredScope = (String) node.getData().get(VerboseJavaScopeSelector.REDUCED_SCOPE);
         }
 
-        boolean included = winnerVersion == null;
+        return new VerboseInfo(
+                omittedReason,
+                winnerVersion,
+                DependencyManagerUtils.getPremanagedVersion(node),
+                DependencyManagerUtils.getPremanagedScope(node),
+                ignoredScope);
+    }
+
+    private static String verboseNodeString(VerboseInfo info, Artifact artifact) {
+        boolean included = info.isIncluded();
 
         StringBuilder buffer = new StringBuilder();
         if (!included) {
@@ -171,22 +187,20 @@ class DependencyTreeBuilder {
         buffer.append(artifact);
 
         List<String> items = new ArrayList<>();
-        String premanagedVersion = DependencyManagerUtils.getPremanagedVersion(node);
-        if (premanagedVersion != null) {
-            items.add("version managed from " + premanagedVersion);
+        if (info.getPremanagedVersion() != null) {
+            items.add("version managed from " + info.getPremanagedVersion());
         }
-        String premanagedScope = DependencyManagerUtils.getPremanagedScope(node);
-        if (premanagedScope != null) {
-            items.add("scope managed from " + premanagedScope);
+        if (info.getPremanagedScope() != null) {
+            items.add("scope managed from " + info.getPremanagedScope());
         }
-        if (ignoredScope != null) {
-            items.add("scope not updated to " + ignoredScope);
+        if (info.getScopeNotUpdatedTo() != null) {
+            items.add("scope not updated to " + info.getScopeNotUpdatedTo());
         }
         if (!included) {
             items.add(
-                    winnerVersion.equals(artifact.getVersion())
+                    VerboseInfo.OMITTED_FOR_DUPLICATE.equals(info.getOmittedReason())
                             ? "omitted for duplicate"
-                            : "omitted for conflict with " + winnerVersion);
+                            : "omitted for conflict with " + info.getWinnerVersion());
         }
 
         if (!items.isEmpty()) {
diff --git a/src/main/java/org/apache/maven/plugins/dependency/tree/JsonDependencyNodeVisitor.java b/src/main/java/org/apache/maven/plugins/dependency/tree/JsonDependencyNodeVisitor.java
index 630afcce..12f10a73 100644
--- a/src/main/java/org/apache/maven/plugins/dependency/tree/JsonDependencyNodeVisitor.java
+++ b/src/main/java/org/apache/maven/plugins/dependency/tree/JsonDependencyNodeVisitor.java
@@ -22,6 +22,9 @@ import java.io.IOException;
 import java.io.UncheckedIOException;
 import java.io.Writer;
 import java.util.HashSet;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.Map;
 import java.util.Set;
 
 import org.apache.maven.artifact.Artifact;
@@ -84,7 +87,7 @@ public class JsonDependencyNodeVisitor extends AbstractSerializingVisitor implem
             return;
         }
         visited.add(node);
-        appendNodeValues(sb, indent, node.getArtifact(), !node.getChildren().isEmpty());
+        appendNodeValues(sb, indent, node, !node.getChildren().isEmpty());
         if (!node.getChildren().isEmpty()) {
             writeChildren(indent, node, sb, visited);
         }
@@ -123,21 +126,61 @@ public class JsonDependencyNodeVisitor extends AbstractSerializingVisitor implem
      *
      * @param sb  the string builder to append to
      * @param indent  the current indent level
-     * @param artifact  the artifact to write
-     * @param hasChildren  true if the artifact has children
+     * @param node  the node whose artifact, and verbose information if present, is written
+     * @param hasChildren  true if the node has children
      */
-    private void appendNodeValues(StringBuilder sb, int indent, Artifact artifact, boolean hasChildren) {
+    private void appendNodeValues(StringBuilder sb, int indent, DependencyNode node, boolean hasChildren) {
+        Artifact artifact = node.getArtifact();
+        Map<String, String> verboseValues = verboseValues(node.getVerboseInfo());
         appendKeyValue(sb, indent, "groupId", artifact.getGroupId());
         appendKeyValue(sb, indent, "artifactId", artifact.getArtifactId());
         appendKeyValue(sb, indent, "version", artifact.getVersion());
         appendKeyValue(sb, indent, "type", artifact.getType());
         appendKeyValue(sb, indent, "scope", artifact.getScope());
         appendKeyValue(sb, indent, "classifier", artifact.getClassifier());
-        if (hasChildren) {
+        if (hasChildren || !verboseValues.isEmpty()) {
             appendKeyValue(sb, indent, "optional", String.valueOf(artifact.isOptional()));
         } else {
             appendKeyWithoutComma(sb, indent, "optional", String.valueOf(artifact.isOptional()));
         }
+
+        Iterator<Map.Entry<String, String>> entries = verboseValues.entrySet().iterator();
+        while (entries.hasNext()) {
+            Map.Entry<String, String> entry = entries.next();
+            sb.append(indent(indent))
+                    .append("\"")
+                    .append(entry.getKey())
+                    .append("\": ")
+                    .append(entry.getValue())
+                    .append(entries.hasNext() || hasChildren ? "," : "")
+                    .append(System.lineSeparator());
+        }
+    }
+
+    /**
+     * The additional JSON values of a verbose node, in output order. The values are already JSON: <code>included</code>
+     * is a boolean, the others are strings.
+     */
+    private static Map<String, String> verboseValues(DependencyNode.VerboseInfo info) {
+        Map<String, String> values = new LinkedHashMap<>();
+        if (info == null) {
+            return values;
+        }
+        values.put("included", String.valueOf(info.isIncluded()));
+        putString(values, "omittedReason", info.getOmittedReason());
+        if (DependencyNode.VerboseInfo.OMITTED_FOR_CONFLICT.equals(info.getOmittedReason())) {
+            putString(values, "winnerVersion", info.getWinnerVersion());
+        }
+        putString(values, "versionManagedFrom", info.getPremanagedVersion());
+        putString(values, "scopeManagedFrom", info.getPremanagedScope());
+        putString(values, "scopeNotUpdatedTo", info.getScopeNotUpdatedTo());
+        return values;
+    }
+
+    private static void putString(Map<String, String> values, String key, String value) {
+        if (value != null) {
+            values.put(key, "\"" + value + "\"");
+        }
     }
     /**
      * Appends a key value pair to the string builder.
diff --git a/src/site/markdown/examples/tree-mojo.md b/src/site/markdown/examples/tree-mojo.md
index fcaa3f69..92afe5a7 100644
--- a/src/site/markdown/examples/tree-mojo.md
+++ b/src/site/markdown/examples/tree-mojo.md
@@ -64,6 +64,7 @@ mvn dependency:tree -DoutputType=<format> -DoutputFile=<filename>
     - `classifier`: The classifier, if any (e.g., empty string if none).
     - `optional`: Whether the dependency is optional (`true` or `false`).
     - `children`: An array of dependency objects with the same structure, representing transitive dependencies.
+    - With `-Dverbose`, every node also has `included` (`false` for a node Maven leaves out of the resolved tree), and, when they apply, `omittedReason` (`duplicate` or `conflict`), `winnerVersion` (the version kept instead, for `conflict`), `versionManagedFrom`, `scopeManagedFrom` and `scopeNotUpdatedTo`. These fields are not written without `-Dverbose`.
 - **Nested Dependencies**: Each dependency in the `children` array follows the same structure, allowing for recursive representation of the dependency tree.
 
 **Example**:
diff --git a/src/test/java/org/apache/maven/plugins/dependency/tree/JsonDependencyNodeVisitorTest.java b/src/test/java/org/apache/maven/plugins/dependency/tree/JsonDependencyNodeVisitorTest.java
new file mode 100644
index 00000000..5d8d48c8
--- /dev/null
+++ b/src/test/java/org/apache/maven/plugins/dependency/tree/JsonDependencyNodeVisitorTest.java
@@ -0,0 +1,115 @@
+/*
+ * 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.apache.maven.plugins.dependency.tree;
+
+import java.io.StringReader;
+import java.io.StringWriter;
+import java.util.Collections;
+
+import jakarta.json.Json;
+import jakarta.json.JsonObject;
+import jakarta.json.JsonReader;
+import jakarta.json.JsonValue;
+import org.apache.maven.artifact.Artifact;
+import org.apache.maven.artifact.DefaultArtifact;
+import org.apache.maven.artifact.handler.DefaultArtifactHandler;
+import org.apache.maven.plugins.dependency.tree.DependencyNode.VerboseInfo;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class JsonDependencyNodeVisitorTest {
+
+    @Test
+    void nodeWithoutVerboseInfoHasNoVerboseFields() {
+        JsonObject json = toJson(new DependencyNode(null, artifact("a", "1.0"), "a"));
+
+        assertEquals(7, json.size());
+        assertEquals("false", json.getString("optional"));
+        assertFalse(json.containsKey("included"));
+    }
+
+    @Test
+    void includedNodeReportsIncludedAndManagement() {
+        DependencyNode node = new DependencyNode(null, artifact("a", "2.0"), "a");
+        node.setVerboseInfo(new VerboseInfo(null, null, "1.0", "test", "compile"));
+
+        JsonObject json = toJson(node);
+
+        assertEquals(JsonValue.TRUE, json.get("included"));
+        assertEquals("1.0", json.getString("versionManagedFrom"));
+        assertEquals("test", json.getString("scopeManagedFrom"));
+        assertEquals("compile", json.getString("scopeNotUpdatedTo"));
+        assertFalse(json.containsKey("omittedReason"));
+        assertFalse(json.containsKey("winnerVersion"));
+    }
+
+    @Test
+    void omittedForDuplicateHasReasonButNoWinnerVersion() {
+        DependencyNode node = new DependencyNode(null, artifact("a", "1.0"), "a");
+        node.setVerboseInfo(new VerboseInfo(VerboseInfo.OMITTED_FOR_DUPLICATE, "1.0", null, null, null));
+
+        JsonObject json = toJson(node);
+
+        assertEquals(JsonValue.FALSE, json.get("included"));
+        assertEquals("duplicate", json.getString("omittedReason"));
+        assertFalse(json.containsKey("winnerVersion"));
+    }
+
+    @Test
+    void omittedForConflictHasWinnerVersion() {
+        DependencyNode node = new DependencyNode(null, artifact("a", "1.0"), "a");
+        node.setVerboseInfo(new VerboseInfo(VerboseInfo.OMITTED_FOR_CONFLICT, "2.0", null, null, null));
+
+        JsonObject json = toJson(node);
+
+        assertEquals(JsonValue.FALSE, json.get("included"));
+        assertEquals("conflict", json.getString("omittedReason"));
+        assertEquals("2.0", json.getString("winnerVersion"));
+    }
+
+    @Test
+    void verboseFieldsOfChildrenAreNestedAndOutputStaysValidJson() {
+        DependencyNode root = new DependencyNode(null, artifact("root", "1.0"), "root");
+        DependencyNode child = new DependencyNode(root, artifact("child", "1.0"), "child");
+        child.setVerboseInfo(new VerboseInfo(VerboseInfo.OMITTED_FOR_DUPLICATE, "1.0", null, null, null));
+        root.setChildren(Collections.singletonList(child));
+
+        JsonObject json = toJson(root);
+
+        assertTrue(json.getJsonArray("children").getJsonObject(0).containsKey("omittedReason"));
+        assertFalse(json.containsKey("included"));
+    }
+
+    private static JsonObject toJson(DependencyNode node) {
+        StringWriter writer = new StringWriter();
+        node.accept(new JsonDependencyNodeVisitor(writer));
+        System.setProperty("jakarta.json.provider", "org.glassfish.json.JsonProviderImpl");
+        try (JsonReader reader = Json.createReader(new StringReader(writer.toString()))) {
+            return reader.readObject();
+        }
+    }
+
+    private static Artifact artifact(String artifactId, String version) {
+        return new DefaultArtifact(
+                "g", artifactId, version, Artifact.SCOPE_COMPILE, "jar", null, new DefaultArtifactHandler("jar"));
+    }
+}