View Javadoc
1   /*
2    * SPDX-FileCopyrightText: Copyright (c) 2011-2026 Yegor Bugayenko
3    * SPDX-License-Identifier: MIT
4    */
5   package com.qulice.maven;
6   
7   import com.google.common.base.Joiner;
8   import com.qulice.spi.Environment;
9   import com.qulice.spi.ValidationException;
10  import java.io.File;
11  import java.nio.charset.StandardCharsets;
12  import java.nio.file.Files;
13  import java.nio.file.Path;
14  import java.util.Collections;
15  import java.util.HashSet;
16  import java.util.Set;
17  import java.util.jar.JarEntry;
18  import java.util.jar.JarOutputStream;
19  import org.apache.maven.artifact.Artifact;
20  import org.apache.maven.plugin.testing.stubs.ArtifactStub;
21  import org.apache.maven.shared.dependency.analyzer.ProjectDependencyAnalysis;
22  import org.apache.maven.shared.dependency.analyzer.ProjectDependencyAnalyzer;
23  import org.junit.jupiter.api.Assertions;
24  import org.junit.jupiter.api.Test;
25  import org.junit.jupiter.api.io.TempDir;
26  
27  /**
28   * Test case for {@link DependenciesValidator} class.
29   * @since 0.3
30   */
31  @SuppressWarnings("PMD.TooManyMethods")
32  final class DependenciesValidatorTest {
33  
34      /**
35       * Plexus role.
36       */
37      private static final String ROLE =
38          ProjectDependencyAnalyzer.class.getName();
39  
40      /**
41       * Plexus hint.
42       */
43      private static final String HINT = "default";
44  
45      /**
46       * Compile scope.
47       */
48      private static final String SCOPE = "compile";
49  
50      /**
51       * Jar type.
52       */
53      private static final String TYPE = "jar";
54  
55      /**
56       * DependencyValidator can pass on when no violations are found.
57       * @throws Exception If something wrong happens inside
58       */
59      @Test
60      void passesIfNoDependencyProblemsFound() throws Exception {
61          Assertions.assertDoesNotThrow(
62              () -> new DependenciesValidator().validate(
63                  DependenciesValidatorTest.envWith(new ProjectDependencyAnalysis())
64              )
65          );
66      }
67  
68      /**
69       * DependencyValidator can catch dependency problems.
70       * @throws Exception If something wrong happens inside
71       */
72      @Test
73      void catchesDependencyProblemsAndThrowsException() throws Exception {
74          final ArtifactStub artifact = new ArtifactStub();
75          artifact.setGroupId("group");
76          artifact.setArtifactId("artifact");
77          artifact.setScope(DependenciesValidatorTest.SCOPE);
78          artifact.setVersion("2.3.4");
79          artifact.setType(DependenciesValidatorTest.TYPE);
80          final Set<Artifact> unused = new HashSet<>();
81          unused.add(artifact);
82          Assertions.assertThrows(
83              ValidationException.class,
84              () -> new DependenciesValidator().validate(
85                  DependenciesValidatorTest.envWith(
86                      new ProjectDependencyAnalysis(
87                          Collections.emptySet(), unused, Collections.emptySet()
88                      )
89                  )
90              )
91          );
92      }
93  
94      /**
95       * DependencyValidator can ignore runtime scope dependencies.
96       * @throws Exception If something wrong happens inside
97       */
98      @Test
99      void ignoresRuntimeScope() throws Exception {
100         final ArtifactStub artifact = new ArtifactStub();
101         artifact.setGroupId("group");
102         artifact.setArtifactId("artifact");
103         artifact.setScope("runtime");
104         artifact.setVersion("2.3.4");
105         artifact.setType(DependenciesValidatorTest.TYPE);
106         final Set<Artifact> unused = new HashSet<>();
107         unused.add(artifact);
108         Assertions.assertDoesNotThrow(
109             () -> new DependenciesValidator().validate(
110                 DependenciesValidatorTest.envWith(
111                     new ProjectDependencyAnalysis(
112                         Collections.emptySet(), Collections.emptySet(), unused
113                     )
114                 )
115             )
116         );
117     }
118 
119     /**
120      * DependencyValidator can exclude used undeclared dependencies.
121      * @throws Exception If something wrong happens inside
122      */
123     @Test
124     void excludesUsedUndeclaredDependencies() throws Exception {
125         final Set<Artifact> used = new HashSet<>();
126         final ArtifactStub artifact = new ArtifactStub();
127         artifact.setGroupId("group");
128         artifact.setArtifactId("artifact");
129         artifact.setScope(DependenciesValidatorTest.SCOPE);
130         artifact.setVersion("2.3.4");
131         artifact.setType(DependenciesValidatorTest.TYPE);
132         used.add(artifact);
133         Assertions.assertDoesNotThrow(
134             () -> new DependenciesValidator().validate(
135                 new MavenEnvironment.Wrap(
136                     new Environment.Mock().withExcludes(
137                         Joiner.on(':').join(
138                             artifact.getGroupId(), artifact.getArtifactId()
139                         )
140                     ),
141                     DependenciesValidatorTest.envWith(
142                         new ProjectDependencyAnalysis(
143                             Collections.emptySet(), used, Collections.emptySet()
144                         )
145                     )
146                 )
147             )
148         );
149     }
150 
151     /**
152      * DependencyValidator can exclude unused declared dependencies.
153      * @throws Exception If something wrong happens inside
154      */
155     @Test
156     void excludesUnusedDeclaredDependencies() throws Exception {
157         final Set<Artifact> unused = new HashSet<>();
158         final ArtifactStub artifact = new ArtifactStub();
159         artifact.setGroupId("othergroup");
160         artifact.setArtifactId("otherartifact");
161         artifact.setScope(DependenciesValidatorTest.SCOPE);
162         artifact.setVersion("1.2.3");
163         artifact.setType(DependenciesValidatorTest.TYPE);
164         unused.add(artifact);
165         Assertions.assertDoesNotThrow(
166             () -> new DependenciesValidator().validate(
167                 new MavenEnvironment.Wrap(
168                     new Environment.Mock().withExcludes(
169                         Joiner.on(':').join(
170                             artifact.getGroupId(), artifact.getArtifactId()
171                         )
172                     ),
173                     DependenciesValidatorTest.envWith(
174                         new ProjectDependencyAnalysis(
175                             Collections.emptySet(), Collections.emptySet(), unused
176                         )
177                     )
178                 )
179             )
180         );
181     }
182 
183     /**
184      * DependencyValidator cannot fail the build when the "unused declared"
185      * dependency is actually referenced by an {@code import} in source, which
186      * is the typical shape of false positives caused by annotations with
187      * source retention or inlined compile-time constants (see issue #782).
188      * @param dir Temporary directory
189      * @throws Exception If something wrong happens inside
190      */
191     @Test
192     void treatsImportedDependencyAsUsed(@TempDir final Path dir)
193         throws Exception {
194         final Path src = DependenciesValidatorTest.sourceRoot(dir);
195         DependenciesValidatorTest.writeJava(
196             src, "com/example/Subject.java",
197             String.join(
198                 String.valueOf('\n'),
199                 "package com.example;",
200                 "import com.fake.Marker;",
201                 "@Marker",
202                 "public class Subject {}",
203                 ""
204             )
205         );
206         Assertions.assertDoesNotThrow(
207             () -> new DependenciesValidator().validate(
208                 DependenciesValidatorTest.envWithUnused(
209                     src,
210                     DependenciesValidatorTest.jar(
211                         dir, "fake.jar", "com/fake/Marker.class"
212                     ),
213                     "com.fake:fake"
214                 )
215             )
216         );
217     }
218 
219     /**
220      * Static imports must also count as evidence that a dependency is used,
221      * since inlined constants are referenced via {@code import static}.
222      * @param dir Temporary directory
223      * @throws Exception If something wrong happens inside
224      */
225     @Test
226     void treatsStaticImportAsUsage(@TempDir final Path dir) throws Exception {
227         final Path src = DependenciesValidatorTest.sourceRoot(dir);
228         DependenciesValidatorTest.writeJava(
229             src, "com/example/UsesConst.java",
230             String.join(
231                 String.valueOf('\n'),
232                 "package com.example;",
233                 "import static com.consts.Constants.VALUE;",
234                 "public class UsesConst { int x = VALUE; }",
235                 ""
236             )
237         );
238         Assertions.assertDoesNotThrow(
239             () -> new DependenciesValidator().validate(
240                 DependenciesValidatorTest.envWithUnused(
241                     src,
242                     DependenciesValidatorTest.jar(
243                         dir, "consts.jar", "com/consts/Constants.class"
244                     ),
245                     "com.consts:consts"
246                 )
247             )
248         );
249     }
250 
251     /**
252      * Wildcard imports must cover any class inside the imported package.
253      * @param dir Temporary directory
254      * @throws Exception If something wrong happens inside
255      */
256     @Test
257     void treatsWildcardImportAsUsage(@TempDir final Path dir) throws Exception {
258         final Path src = DependenciesValidatorTest.sourceRoot(dir);
259         DependenciesValidatorTest.writeJava(
260             src, "com/example/UsesWild.java",
261             String.join(
262                 String.valueOf('\n'),
263                 "package com.example;",
264                 "import com.wild.*;",
265                 "public class UsesWild {}",
266                 ""
267             )
268         );
269         Assertions.assertDoesNotThrow(
270             () -> new DependenciesValidator().validate(
271                 DependenciesValidatorTest.envWithUnused(
272                     src,
273                     DependenciesValidatorTest.jar(
274                         dir, "wild.jar", "com/wild/Thing.class"
275                     ),
276                     "com.wild:wild"
277                 )
278             )
279         );
280     }
281 
282     /**
283      * Without any matching import, an "unused declared" compile-scope
284      * dependency must still fail the build even when sources exist.
285      * @param dir Temporary directory
286      * @throws Exception If something wrong happens inside
287      */
288     @Test
289     void stillFailsWithoutMatchingImport(@TempDir final Path dir)
290         throws Exception {
291         final Path src = DependenciesValidatorTest.sourceRoot(dir);
292         DependenciesValidatorTest.writeJava(
293             src, "com/example/Other.java",
294             String.join(
295                 String.valueOf('\n'),
296                 "package com.example;",
297                 "import java.util.List;",
298                 "public class Other {}",
299                 ""
300             )
301         );
302         Assertions.assertThrows(
303             ValidationException.class,
304             () -> new DependenciesValidator().validate(
305                 DependenciesValidatorTest.envWithUnused(
306                     src,
307                     DependenciesValidatorTest.jar(
308                         dir, "alone.jar", "com/alone/Class.class"
309                     ),
310                     "com.alone:alone"
311                 )
312             ),
313             "a declared dependency that is neither referenced in bytecode nor imported in source must not pass validation"
314         );
315     }
316 
317     /**
318      * Build a MavenEnvironment wired with a given dependency analysis.
319      * @param analysis Dependency analysis to inject
320      * @return Wired environment
321      * @throws Exception If something wrong happens inside
322      */
323     private static MavenEnvironment envWith(
324         final ProjectDependencyAnalysis analysis
325     ) throws Exception {
326         return new MavenEnvironmentMocker().inPlexus(
327             DependenciesValidatorTest.ROLE,
328             DependenciesValidatorTest.HINT,
329             new FakeProjectDependencyAnalyzer(analysis)
330         ).mock();
331     }
332 
333     /**
334      * Build a MavenEnvironment where exactly one artifact is reported as
335      * "unused declared" and the given source root is part of the project.
336      * @param src Directory containing Java sources
337      * @param jar JAR file to attach to the artifact
338      * @param coord Artifact coordinate in the form {@code groupId:artifactId}
339      * @return Wired environment
340      * @throws Exception If something wrong happens inside
341      */
342     private static MavenEnvironment envWithUnused(final Path src,
343         final File jar, final String coord) throws Exception {
344         final String[] parts = coord.split(":");
345         final ArtifactStub artifact = new ArtifactStub();
346         artifact.setGroupId(parts[0]);
347         artifact.setArtifactId(parts[1]);
348         artifact.setScope(DependenciesValidatorTest.SCOPE);
349         artifact.setVersion("1.0.0");
350         artifact.setType(DependenciesValidatorTest.TYPE);
351         artifact.setFile(jar);
352         final Set<Artifact> unused = new HashSet<>();
353         unused.add(artifact);
354         final MavenEnvironment env = DependenciesValidatorTest.envWith(
355             new ProjectDependencyAnalysis(
356                 Collections.emptySet(), Collections.emptySet(), unused
357             )
358         );
359         env.project().addCompileSourceRoot(src.toString());
360         return env;
361     }
362 
363     /**
364      * Create a JAR file at the given path containing the given class entries.
365      * @param dir Parent directory
366      * @param name JAR file name
367      * @param entries Names of class entries to include
368      * @return The created JAR file
369      * @throws Exception If something wrong happens inside
370      */
371     private static File jar(final Path dir, final String name,
372         final String... entries) throws Exception {
373         final File jar = dir.resolve(name).toFile();
374         try (
375             JarOutputStream out = new JarOutputStream(
376                 Files.newOutputStream(jar.toPath())
377             )
378         ) {
379             for (final String entry : entries) {
380                 out.putNextEntry(new JarEntry(entry));
381                 out.write(new byte[]{0});
382                 out.closeEntry();
383             }
384         }
385         return jar;
386     }
387 
388     /**
389      * Create a compile source root directory under the given temp dir.
390      * @param dir Temporary directory
391      * @return Created source root
392      * @throws Exception If something wrong happens inside
393      */
394     private static Path sourceRoot(final Path dir) throws Exception {
395         final Path src = dir.resolve("src").resolve("main").resolve("java");
396         Files.createDirectories(src);
397         return src;
398     }
399 
400     /**
401      * Write the given Java source file under the source root.
402      * @param src Source root
403      * @param path Relative path for the source file
404      * @param content File content
405      * @throws Exception If something wrong happens inside
406      */
407     private static void writeJava(final Path src, final String path,
408         final String content) throws Exception {
409         final Path target = src.resolve(path);
410         Files.createDirectories(target.getParent());
411         Files.writeString(target, content, StandardCharsets.UTF_8);
412     }
413 }