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