You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maven.apache.org by rm...@apache.org on 2025/10/06 18:51:24 UTC

(maven-dependency-plugin) 06/07: some draft of a more generic detection of used _unused_ dependencies

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

rmannibucau pushed a commit to branch pr-459
in repository https://gitbox.apache.org/repos/asf/maven-dependency-plugin.git

commit f4fd3bd5255b70a8b7648fbe71d797deb98394fc
Author: Romain Manni-Bucau <rm...@gmail.com>
AuthorDate: Mon Oct 6 17:17:56 2025 +0200

    some draft of a more generic detection of used _unused_ dependencies
---
 pom.xml                                            |  25 ++-
 .../dependency/analyze/AbstractAnalyzeMojo.java    | 156 +++++++++++++++-
 .../analyze/TestAnalyzeIndirectDependency.java     | 169 +++++++++++++++++
 .../plugins/dependency/analyze/main/JsonpMain.java |  31 +++
 .../plugins/dependency/analyze/main/Slf4jMain.java |  31 +++
 .../exclusion/AnalyzeExclusionsMojoTest.java       | 183 +-----------------
 .../plugins/dependency/testUtils/TestLog.java      | 208 +++++++++++++++++++++
 7 files changed, 611 insertions(+), 192 deletions(-)

diff --git a/pom.xml b/pom.xml
index c0fbfc7a..92d1825e 100644
--- a/pom.xml
+++ b/pom.xml
@@ -65,7 +65,7 @@ under the License.
   </contributors>
 
   <prerequisites>
-    <maven>3.6.3</maven>
+    <maven>${mavenVersion}</maven>
   </prerequisites>
 
   <scm>
@@ -95,6 +95,9 @@ under the License.
     <resolverVersion>1.9.24</resolverVersion>
     <slf4jVersion>1.7.36</slf4jVersion>
     <jettyVersion>9.4.58.v20250814</jettyVersion>
+    <jettyVersion>9.4.57.v20241219</jettyVersion>
+    <johnzonVersion>2.0.1</johnzonVersion>
+    <glassfishVersion>2.0.1</glassfishVersion>
     <mockito.version>4.11.0</mockito.version>
     <plexus-archiver.version>4.10.1</plexus-archiver.version>
     <pluginTestingVersion>3.3.0</pluginTestingVersion>
@@ -327,7 +330,13 @@ under the License.
     <dependency>
       <groupId>org.glassfish</groupId>
       <artifactId>jakarta.json</artifactId>
-      <version>2.0.1</version>
+      <version>${glassfishVersion}</version>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.apache.johnzon</groupId>
+      <artifactId>johnzon-core</artifactId>
+      <version>${johnzonVersion}</version>
       <scope>test</scope>
     </dependency>
     <dependency>
@@ -406,6 +415,13 @@ under the License.
       <version>${slf4jVersion}</version>
       <scope>test</scope>
     </dependency>
+    <dependency>
+      <groupId>org.tomitribe</groupId>
+      <artifactId>tomitribe-util</artifactId>
+      <version>1.3.10</version>
+      <scope>test</scope>
+    </dependency>
+
   </dependencies>
 
   <build>
@@ -470,6 +486,11 @@ under the License.
         <configuration>
           <!-- Need more heap space in order to run the unit tests !-->
           <argLine>-Xmx512m</argLine>
+          <systemPropertyVariables>
+            <slf4jVersion>${slf4jVersion}</slf4jVersion>
+            <johnzonVersion>${johnzonVersion}</johnzonVersion>
+            <glassfishVersion>${glassfishVersion}</glassfishVersion>
+          </systemPropertyVariables>
           <rerunFailingTestsCount>2</rerunFailingTestsCount>
           <redirectTestOutputToFile>true</redirectTestOutputToFile>
         </configuration>
diff --git a/src/main/java/org/apache/maven/plugins/dependency/analyze/AbstractAnalyzeMojo.java b/src/main/java/org/apache/maven/plugins/dependency/analyze/AbstractAnalyzeMojo.java
index e49ddb4a..9ffdabdf 100644
--- a/src/main/java/org/apache/maven/plugins/dependency/analyze/AbstractAnalyzeMojo.java
+++ b/src/main/java/org/apache/maven/plugins/dependency/analyze/AbstractAnalyzeMojo.java
@@ -19,16 +19,27 @@
 package org.apache.maven.plugins.dependency.analyze;
 
 import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
 import java.io.StringWriter;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
 import java.util.Iterator;
 import java.util.LinkedHashMap;
 import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 import java.util.Set;
+import java.util.jar.JarEntry;
+import java.util.jar.JarFile;
+import java.util.stream.Stream;
 
+import org.objectweb.asm.ClassReader;
+import org.objectweb.asm.ClassVisitor;
+import org.objectweb.asm.MethodVisitor;
 import org.apache.maven.artifact.Artifact;
 import org.apache.maven.artifact.resolver.filter.ArtifactFilter;
 import org.apache.maven.plugin.AbstractMojo;
@@ -43,6 +54,14 @@ import org.apache.maven.shared.dependency.analyzer.ProjectDependencyAnalyzerExce
 import org.codehaus.plexus.PlexusContainer;
 import org.codehaus.plexus.component.repository.exception.ComponentLookupException;
 import org.codehaus.plexus.util.xml.PrettyPrintXMLWriter;
+import org.objectweb.asm.ModuleVisitor;
+import org.objectweb.asm.Type;
+
+import static java.util.Collections.list;
+import static java.util.stream.Collectors.toList;
+import static java.util.stream.Collectors.toSet;
+import static org.objectweb.asm.Opcodes.ASM9;
+import static org.objectweb.asm.Opcodes.INVOKESTATIC;
 
 /**
  * Analyzes the dependencies of this project and determines which are: used and declared; used and undeclared; unused
@@ -214,10 +233,14 @@ public abstract class AbstractAnalyzeMojo extends AbstractMojo {
      * <code>org.apache.</code>, and <code>:::*-SNAPSHOT</code> matches all snapshot artifacts.
      * </p>
      *
+     * <p>Certain dependencies that are known to be used and loaded by reflection
+     * are always ignored. This includes {@code org.slf4j:slf4j-simple::}
+     * and {@code org.glassfish:javax.json::}.</p>
+     *
      * @since 2.10
      */
     @Parameter
-    private String[] ignoredUnusedDeclaredDependencies;
+    private String[] ignoredUnusedDeclaredDependencies = new String[0];
 
     /**
      * List of dependencies that are ignored if they are in not test scope but are only used in test classes.
@@ -361,7 +384,6 @@ public abstract class AbstractAnalyzeMojo extends AbstractMojo {
 
         ignoredUnusedDeclared.addAll(filterDependencies(unusedDeclared, ignoredDependencies));
         ignoredUnusedDeclared.addAll(filterDependencies(unusedDeclared, ignoredUnusedDeclaredDependencies));
-        ignoredUnusedDeclared.addAll(filterDependencies(unusedDeclared, unconditionallyIgnoredDeclaredDependencies));
 
         if (ignoreAllNonTestScoped) {
             ignoredNonTestScope.addAll(filterDependencies(nonTestScope, new String[] {"*"}));
@@ -393,11 +415,15 @@ public abstract class AbstractAnalyzeMojo extends AbstractMojo {
         }
 
         if (!unusedDeclared.isEmpty()) {
-            logDependencyWarning("Unused declared dependencies found:");
-
-            logArtifacts(unusedDeclared, true);
-            reported = true;
-            warning = true;
+            final Set<String> declaredSpi = scanForSpiUsage(usedDeclared, usedUndeclaredWithClasses);
+            cleanupUnused(declaredSpi, unusedDeclared);
+            if (!unusedDeclared.isEmpty()) {
+                logDependencyWarning("Unused declared dependencies found:");
+
+                logArtifacts(unusedDeclared, true);
+                reported = true;
+                warning = true;
+            }
         }
 
         if (!nonTestScope.isEmpty()) {
@@ -444,6 +470,119 @@ public abstract class AbstractAnalyzeMojo extends AbstractMojo {
         return warning;
     }
 
+    // todo: enhance analyzer (dependency) to do it since it already visits classes
+    //       will save some time
+    private Set<String> scanForSpiUsage(final Set<Artifact> usedDeclared,
+                                        final Map<Artifact, Set<String>> usedUndeclaredWithClasses) {
+        return Stream.concat(
+                usedDeclared.stream().flatMap(this::findUsedSpi),
+                usedUndeclaredWithClasses.keySet().stream().flatMap(this::findUsedSpi))
+                .collect(toSet());
+    }
+
+    private Stream<String> findUsedSpi(final Artifact artifact) {
+        try (final JarFile jar = new JarFile(artifact.getFile())) {
+            return list(jar.entries()).stream()
+                    .filter(entry -> entry.getName().endsWith(".class"))
+                    .flatMap(entry -> {
+                        final ClassReader classReader;
+                        try {
+                            classReader = new ClassReader(jar.getInputStream(entry));
+                        } catch (IOException e) {
+                            return Stream.empty();
+                        }
+                        final Set<String> spi = new HashSet<>();
+                        classReader.accept(new ClassVisitor(ASM9) {
+                            @Override
+                            public MethodVisitor visitMethod(final int access,
+                                                             final String name,
+                                                             final String descriptor,
+                                                             final String signature,
+                                                             final String[] exceptions) {
+                                return new MethodVisitor(ASM9) {
+                                    private Type lastType = null;
+
+                                    @Override
+                                    public void visitLdcInsn(final Object value) {
+                                        if (value instanceof Type) {
+                                            lastType = (Type) value;
+                                        }
+                                    }
+
+                                    @Override
+                                    public void visitMethodInsn(final int opcode,
+                                                                final String owner,
+                                                                final String name,
+                                                                final String descriptor,
+                                                                final boolean isInterface) {
+                                        if (opcode == INVOKESTATIC &&
+                                                Objects.equals(owner, "java/util/ServiceLoader") &&
+                                                Objects.equals(name, "load")) {
+                                            spi.add(lastType.getClassName());
+                                        }
+                                        lastType = null;
+                                    }
+                                };
+                            }
+                        }, 0);
+                        return spi.stream();
+                    })
+                    .collect(toList()) // materialize before closing the jar
+                    .stream();
+        } catch (final IOException ioe) {
+            return Stream.empty();
+        }
+    }
+
+    // here we try to detect "well-known" indirect patterns to remove false warnings
+    private void cleanupUnused(final Set<String> spi, final Set<Artifact> unusedDeclared) {
+        unusedDeclared.removeIf(this::isSlf4jBinding);
+        unusedDeclared.removeIf(it -> hasUsedSPIImpl(spi, it));
+    }
+
+    // mainly for v1.x line but doesn't hurt much for v2
+    // TODO: enhance to ensure there is a single binding else just log a warning for all
+    //       and maybe even handle version?
+    private boolean isSlf4jBinding(final Artifact artifact) {
+        try (final JarFile file = new JarFile(artifact.getFile())) {
+            return file.getEntry("org/slf4j/impl/StaticLoggerBinder.class") != null;
+        } catch (final IOException e) {
+            return false;
+        }
+    }
+
+    private boolean hasUsedSPIImpl(final Set<String> usedSpi, final Artifact artifact) {
+        final Set<String> spi;
+        try (final JarFile file = new JarFile(artifact.getFile())) {
+            spi = list(file.entries()).stream()
+                    .filter(it -> it.getName().startsWith("META-INF/services/") && !it.isDirectory())
+                    .map(it -> it.getName().substring("META-INF/services/".length()))
+                    .collect(toSet());
+
+            // java >= 9
+            final JarEntry moduleEntry = file.getJarEntry("module-info.class");
+            if (moduleEntry != null) {
+                try (final InputStream in = file.getInputStream(moduleEntry)) {
+                    final ClassReader cr = new ClassReader(in);
+                    cr.accept(new ClassVisitor(ASM9) {
+                        @Override
+                        public ModuleVisitor visitModule(String name, int access, String version) {
+                            return new ModuleVisitor(ASM9) {
+                                @Override
+                                public void visitProvide(final String service, final String[] providers) {
+                                    spi.add(service.replace('/', '.'));
+                                }
+                            };
+                        }
+                    }, 0);
+                }
+            }
+        } catch (final IOException e) {
+            return false;
+        }
+        return usedSpi.stream().anyMatch(spi::contains);
+    }
+
     private void filterArtifactsByScope(Set<Artifact> artifacts, String scope) {
         artifacts.removeIf(artifact -> artifact.getScope().equals(scope));
     }
@@ -554,7 +693,8 @@ public abstract class AbstractAnalyzeMojo extends AbstractMojo {
     }
 
     private List<Artifact> filterDependencies(Set<Artifact> artifacts, String[] excludes) {
-        ArtifactFilter filter = new StrictPatternExcludesArtifactFilter(Arrays.asList(excludes));
+        ArtifactFilter filter = new StrictPatternExcludesArtifactFilter(excludes == null ?
+                Collections.emptyList() :Arrays.asList(excludes));
         List<Artifact> result = new ArrayList<>();
 
         for (Iterator<Artifact> it = artifacts.iterator(); it.hasNext(); ) {
diff --git a/src/test/java/org/apache/maven/plugins/dependency/analyze/TestAnalyzeIndirectDependency.java b/src/test/java/org/apache/maven/plugins/dependency/analyze/TestAnalyzeIndirectDependency.java
new file mode 100644
index 00000000..09f6ff78
--- /dev/null
+++ b/src/test/java/org/apache/maven/plugins/dependency/analyze/TestAnalyzeIndirectDependency.java
@@ -0,0 +1,169 @@
+/*
+ * 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.analyze;
+
+import jakarta.json.spi.JsonProvider;
+import org.apache.johnzon.core.JsonProviderImpl;
+import org.apache.maven.artifact.Artifact;
+import org.apache.maven.artifact.DefaultArtifact;
+import org.apache.maven.artifact.handler.DefaultArtifactHandler;
+import org.apache.maven.model.Build;
+import org.apache.maven.plugin.MojoExecutionException;
+import org.apache.maven.plugin.logging.Log;
+import org.apache.maven.plugin.testing.ResolverExpressionEvaluatorStub;
+import org.apache.maven.plugins.dependency.analyze.main.JsonpMain;
+import org.apache.maven.plugins.dependency.analyze.main.Slf4jMain;
+import org.apache.maven.plugins.dependency.testUtils.TestLog;
+import org.apache.maven.project.MavenProject;
+import org.apache.maven.shared.dependency.analyzer.DefaultClassAnalyzer;
+import org.apache.maven.shared.dependency.analyzer.DefaultProjectDependencyAnalyzer;
+import org.apache.maven.shared.dependency.analyzer.ProjectDependencyAnalyzer;
+import org.apache.maven.shared.dependency.analyzer.asm.ASMDependencyAnalyzer;
+import org.codehaus.plexus.component.configurator.BasicComponentConfigurator;
+import org.codehaus.plexus.component.configurator.expression.ExpressionEvaluator;
+import org.codehaus.plexus.configuration.DefaultPlexusConfiguration;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.slf4j.LoggerFactory;
+import org.slf4j.impl.SimpleLoggerFactory;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.lang.reflect.Field;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.util.HashSet;
+
+import static java.util.Arrays.asList;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.tomitribe.util.JarLocation.jarLocation;
+
+class TestAnalyzeIndirectDependency {
+    @Test
+    void jakartaJavaxEE(@TempDir final Path work) throws Exception {
+        final String glassfishVersion = System.getProperty("glassfishVersion", "2.0.1");
+        final String johnzonVersion = System.getProperty("johnzonVersion", "2.0.1");
+
+        assertEquals(
+                "[info] No dependency problems found",
+                exec(
+                        work,
+                        base -> {
+                            final String resource = JsonpMain.class.getName().replace('.', '/') + ".class";
+                            final Path target = base.resolve(resource);
+                            Files.createDirectories(target.getParent());
+                            try (final InputStream is = Thread.currentThread()
+                                    .getContextClassLoader()
+                                    .getResourceAsStream(resource)) {
+                                Files.copy(is, target, StandardCopyOption.REPLACE_EXISTING);
+                            }
+                        },
+                        artifact("org.glassfish", "jakarta.json", glassfishVersion, JsonProvider.class),
+                        artifact("org.apache.johnzon", "johnzon-core", johnzonVersion, JsonProviderImpl.class)));
+    }
+
+    /* Ensure we do not have
+    [warn] Unused declared dependencies found:
+    [warn]    org.slf4j:slf4j-simple:jar:1.7.36:compile
+     */
+    @Test
+    void slf4j(@TempDir final Path work) throws Exception {
+        final String slf4jVersion = System.getProperty("slf4jVersion", "1.7.36");
+        assertEquals(
+                "[info] No dependency problems found",
+                exec(
+                        work,
+                        base -> {
+                            final String resource = Slf4jMain.class.getName().replace('.', '/') + ".class";
+                            final Path target = base.resolve(resource);
+                            Files.createDirectories(target.getParent());
+                            try (final InputStream is = Thread.currentThread()
+                                    .getContextClassLoader()
+                                    .getResourceAsStream(resource)) {
+                                Files.copy(is, target, StandardCopyOption.REPLACE_EXISTING);
+                            }
+                        },
+                        artifact("org.slf4j", "slf4j-api", slf4jVersion, LoggerFactory.class),
+                        artifact("org.slf4j", "slf4j-simple", slf4jVersion, SimpleLoggerFactory.class)));
+    }
+
+    private String exec(final Path work, final IOConsumer<Path> classesFiller, final Artifact... artifacts) throws Exception {
+        final Path classes = Files.createDirectories(work.resolve("target/classes"));
+        final Build build = new Build();
+        build.setOutputDirectory(classes.toString());
+        build.setTestOutputDirectory(Files.createDirectories(work.resolve("target/test-classes")).toString());
+
+        final MavenProject project = new MavenProject();
+        project.setGroupId("g");
+        project.setArtifactId("test");
+        project.setVersion("build");
+        project.setBuild(build);
+        project.setArtifacts(new HashSet<>(asList(artifacts)));
+        project.setDependencyArtifacts(project.getArtifacts());
+        classesFiller.accept(classes);
+
+        final Log log = new TestLog();
+
+        final DefaultProjectDependencyAnalyzer analyzer = new DefaultProjectDependencyAnalyzer();
+        final Field classAnalyzer = DefaultProjectDependencyAnalyzer.class.getDeclaredField("classAnalyzer");
+        classAnalyzer.setAccessible(true);
+        classAnalyzer.set(analyzer, new DefaultClassAnalyzer());
+        final Field dependencyAnalyzer = DefaultProjectDependencyAnalyzer.class.getDeclaredField("dependencyAnalyzer");
+        dependencyAnalyzer.setAccessible(true);
+        dependencyAnalyzer.set(analyzer, new ASMDependencyAnalyzer());
+
+        final AnalyzeMojo mojo = new AnalyzeMojo(null, project) {
+            @Override
+            protected ProjectDependencyAnalyzer createProjectDependencyAnalyzer() throws MojoExecutionException {
+                return analyzer;
+            }
+
+            @Override
+            public Log getLog() {
+                return log;
+            }
+        };
+
+        final ExpressionEvaluator evaluator = new ResolverExpressionEvaluatorStub();
+
+        final DefaultPlexusConfiguration configuration = new DefaultPlexusConfiguration("configuration");
+        configuration.addChild("ignoredPackagings", "pom");
+        configuration.addChild("outputDirectory", project.getBuild().getOutputDirectory());
+
+        new BasicComponentConfigurator()
+                .configureComponent(mojo, configuration, evaluator, null);
+
+        mojo.execute();
+
+        return log.toString().trim();
+    }
+
+    private DefaultArtifact artifact(final String groupId, final String artifactId, final String slf4jVersion, final Class<?> marker) {
+        final DefaultArtifact artifact = new DefaultArtifact(
+                groupId, artifactId, slf4jVersion,
+                "compile", "jar", "", new DefaultArtifactHandler());
+        artifact.setFile(jarLocation(marker));
+        return artifact;
+    }
+
+    private interface IOConsumer<T> {
+        void accept(T arg) throws IOException;
+    }
+}
diff --git a/src/test/java/org/apache/maven/plugins/dependency/analyze/main/JsonpMain.java b/src/test/java/org/apache/maven/plugins/dependency/analyze/main/JsonpMain.java
new file mode 100644
index 00000000..2af60894
--- /dev/null
+++ b/src/test/java/org/apache/maven/plugins/dependency/analyze/main/JsonpMain.java
@@ -0,0 +1,31 @@
+/*
+ * 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.analyze.main;
+
+import jakarta.json.Json;
+
+public final class JsonpMain {
+    private JsonpMain() {
+        // no-op
+    }
+
+    public static void main(final String... args) {
+        Json.createValue("");
+    }
+}
diff --git a/src/test/java/org/apache/maven/plugins/dependency/analyze/main/Slf4jMain.java b/src/test/java/org/apache/maven/plugins/dependency/analyze/main/Slf4jMain.java
new file mode 100644
index 00000000..b2aa4f34
--- /dev/null
+++ b/src/test/java/org/apache/maven/plugins/dependency/analyze/main/Slf4jMain.java
@@ -0,0 +1,31 @@
+/*
+ * 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.analyze.main;
+
+import org.slf4j.LoggerFactory;
+
+public final class Slf4jMain {
+    private Slf4jMain() {
+        // no-op
+    }
+
+    public static void main(final String... args) {
+        LoggerFactory.getLogger(Slf4jMain.class).info("");
+    }
+}
diff --git a/src/test/java/org/apache/maven/plugins/dependency/exclusion/AnalyzeExclusionsMojoTest.java b/src/test/java/org/apache/maven/plugins/dependency/exclusion/AnalyzeExclusionsMojoTest.java
index d0da7947..3f962198 100644
--- a/src/test/java/org/apache/maven/plugins/dependency/exclusion/AnalyzeExclusionsMojoTest.java
+++ b/src/test/java/org/apache/maven/plugins/dependency/exclusion/AnalyzeExclusionsMojoTest.java
@@ -19,8 +19,6 @@
 package org.apache.maven.plugins.dependency.exclusion;
 
 import java.io.File;
-import java.io.PrintWriter;
-import java.io.StringWriter;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
@@ -35,8 +33,8 @@ import org.apache.maven.model.Exclusion;
 import org.apache.maven.model.InputLocation;
 import org.apache.maven.model.InputSource;
 import org.apache.maven.plugin.MojoExecutionException;
-import org.apache.maven.plugin.logging.Log;
 import org.apache.maven.plugins.dependency.AbstractDependencyMojoTestCase;
+import org.apache.maven.plugins.dependency.testUtils.TestLog;
 import org.apache.maven.plugins.dependency.testUtils.stubs.DependencyProjectStub;
 import org.apache.maven.plugins.dependency.utils.ResolverUtil;
 import org.apache.maven.project.MavenProject;
@@ -234,183 +232,4 @@ public class AnalyzeExclusionsMojoTest extends AbstractDependencyMojoTestCase {
         exclusion.setLocation("", new InputLocation(1, 1, inputSource));
         return exclusion;
     }
-
-    static class TestLog implements Log {
-        StringBuilder sb = new StringBuilder();
-
-        /**
-         * {@inheritDoc}
-         */
-        @Override
-        public void debug(CharSequence content) {
-            print("debug", content);
-        }
-
-        /**
-         * {@inheritDoc}
-         */
-        @Override
-        public void debug(CharSequence content, Throwable error) {
-            print("debug", content, error);
-        }
-
-        /**
-         * {@inheritDoc}
-         */
-        @Override
-        public void debug(Throwable error) {
-            print("debug", error);
-        }
-
-        /**
-         * {@inheritDoc}
-         */
-        @Override
-        public void info(CharSequence content) {
-            print("info", content);
-        }
-
-        /**
-         * {@inheritDoc}
-         */
-        @Override
-        public void info(CharSequence content, Throwable error) {
-            print("info", content, error);
-        }
-
-        /**
-         * {@inheritDoc}
-         */
-        @Override
-        public void info(Throwable error) {
-            print("info", error);
-        }
-
-        /**
-         * {@inheritDoc}
-         */
-        @Override
-        public void warn(CharSequence content) {
-            print("warn", content);
-        }
-
-        /**
-         * {@inheritDoc}
-         */
-        @Override
-        public void warn(CharSequence content, Throwable error) {
-            print("warn", content, error);
-        }
-
-        /**
-         * {@inheritDoc}
-         */
-        @Override
-        public void warn(Throwable error) {
-            print("warn", error);
-        }
-
-        /**
-         * {@inheritDoc}
-         */
-        @Override
-        public void error(CharSequence content) {
-            print("error", content);
-        }
-
-        /**
-         * {@inheritDoc}
-         */
-        @Override
-        public void error(CharSequence content, Throwable error) {
-            StringWriter sWriter = new StringWriter();
-            PrintWriter pWriter = new PrintWriter(sWriter);
-
-            error.printStackTrace(pWriter);
-
-            System.err.println(
-                    "[error] " + content.toString() + System.lineSeparator() + System.lineSeparator() + sWriter);
-        }
-
-        /**
-         * @see org.apache.maven.plugin.logging.Log#error(java.lang.Throwable)
-         */
-        @Override
-        public void error(Throwable error) {
-            StringWriter sWriter = new StringWriter();
-            PrintWriter pWriter = new PrintWriter(sWriter);
-
-            error.printStackTrace(pWriter);
-
-            System.err.println("[error] " + sWriter);
-        }
-
-        /**
-         * @see org.apache.maven.plugin.logging.Log#isDebugEnabled()
-         */
-        @Override
-        public boolean isDebugEnabled() {
-            return false;
-        }
-
-        /**
-         * @see org.apache.maven.plugin.logging.Log#isInfoEnabled()
-         */
-        @Override
-        public boolean isInfoEnabled() {
-            return true;
-        }
-
-        /**
-         * @see org.apache.maven.plugin.logging.Log#isWarnEnabled()
-         */
-        @Override
-        public boolean isWarnEnabled() {
-            return true;
-        }
-
-        /**
-         * @see org.apache.maven.plugin.logging.Log#isErrorEnabled()
-         */
-        @Override
-        public boolean isErrorEnabled() {
-            return true;
-        }
-
-        private void print(String prefix, CharSequence content) {
-            sb.append("[")
-                    .append(prefix)
-                    .append("] ")
-                    .append(content.toString())
-                    .append(System.lineSeparator());
-        }
-
-        private void print(String prefix, Throwable error) {
-            StringWriter sWriter = new StringWriter();
-            PrintWriter pWriter = new PrintWriter(sWriter);
-
-            error.printStackTrace(pWriter);
-
-            sb.append("[").append(prefix).append("] ").append(sWriter).append(System.lineSeparator());
-        }
-
-        private void print(String prefix, CharSequence content, Throwable error) {
-            StringWriter sWriter = new StringWriter();
-            PrintWriter pWriter = new PrintWriter(sWriter);
-
-            error.printStackTrace(pWriter);
-
-            sb.append("[")
-                    .append(prefix)
-                    .append("] ")
-                    .append(content.toString())
-                    .append(System.lineSeparator())
-                    .append(System.lineSeparator());
-            sb.append(sWriter).append(System.lineSeparator());
-        }
-
-        protected String getContent() {
-            return sb.toString();
-        }
-    }
 }
diff --git a/src/test/java/org/apache/maven/plugins/dependency/testUtils/TestLog.java b/src/test/java/org/apache/maven/plugins/dependency/testUtils/TestLog.java
new file mode 100644
index 00000000..80e73223
--- /dev/null
+++ b/src/test/java/org/apache/maven/plugins/dependency/testUtils/TestLog.java
@@ -0,0 +1,208 @@
+/*
+ * 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.testUtils;
+
+import org.apache.maven.plugin.logging.Log;
+
+import java.io.PrintWriter;
+import java.io.StringWriter;
+
+public class TestLog implements Log {
+    private final StringBuilder sb = new StringBuilder();
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public void debug(CharSequence content) {
+        print("debug", content);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public void debug(CharSequence content, Throwable error) {
+        print("debug", content, error);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public void debug(Throwable error) {
+        print("debug", error);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public void info(CharSequence content) {
+        print("info", content);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public void info(CharSequence content, Throwable error) {
+        print("info", content, error);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public void info(Throwable error) {
+        print("info", error);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public void warn(CharSequence content) {
+        print("warn", content);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public void warn(CharSequence content, Throwable error) {
+        print("warn", content, error);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public void warn(Throwable error) {
+        print("warn", error);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public void error(CharSequence content) {
+        print("error", content);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public void error(CharSequence content, Throwable error) {
+        StringWriter sWriter = new StringWriter();
+        PrintWriter pWriter = new PrintWriter(sWriter);
+
+        error.printStackTrace(pWriter);
+
+        System.err.println(
+                "[error] " + content.toString() + System.lineSeparator() + System.lineSeparator() + sWriter);
+    }
+
+    /**
+     * @see org.apache.maven.plugin.logging.Log#error(java.lang.Throwable)
+     */
+    @Override
+    public void error(Throwable error) {
+        StringWriter sWriter = new StringWriter();
+        PrintWriter pWriter = new PrintWriter(sWriter);
+
+        error.printStackTrace(pWriter);
+
+        System.err.println("[error] " + sWriter);
+    }
+
+    /**
+     * @see org.apache.maven.plugin.logging.Log#isDebugEnabled()
+     */
+    @Override
+    public boolean isDebugEnabled() {
+        return false;
+    }
+
+    /**
+     * @see org.apache.maven.plugin.logging.Log#isInfoEnabled()
+     */
+    @Override
+    public boolean isInfoEnabled() {
+        return true;
+    }
+
+    /**
+     * @see org.apache.maven.plugin.logging.Log#isWarnEnabled()
+     */
+    @Override
+    public boolean isWarnEnabled() {
+        return true;
+    }
+
+    /**
+     * @see org.apache.maven.plugin.logging.Log#isErrorEnabled()
+     */
+    @Override
+    public boolean isErrorEnabled() {
+        return true;
+    }
+
+    private void print(String prefix, CharSequence content) {
+        sb.append("[")
+                .append(prefix)
+                .append("] ")
+                .append(content.toString())
+                .append(System.lineSeparator());
+    }
+
+    private void print(String prefix, Throwable error) {
+        StringWriter sWriter = new StringWriter();
+        PrintWriter pWriter = new PrintWriter(sWriter);
+
+        error.printStackTrace(pWriter);
+
+        sb.append("[").append(prefix).append("] ").append(sWriter).append(System.lineSeparator());
+    }
+
+    private void print(String prefix, CharSequence content, Throwable error) {
+        StringWriter sWriter = new StringWriter();
+        PrintWriter pWriter = new PrintWriter(sWriter);
+
+        error.printStackTrace(pWriter);
+
+        sb.append("[")
+                .append(prefix)
+                .append("] ")
+                .append(content.toString())
+                .append(System.lineSeparator())
+                .append(System.lineSeparator());
+        sb.append(sWriter).append(System.lineSeparator());
+    }
+
+    @Override
+    public String toString() {
+        return getContent();
+    }
+
+    public String getContent() {
+        return sb.toString();
+    }
+}
\ No newline at end of file