Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Detect and allow failing for unused/undeclared dependencies #62

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
Expand Down Expand Up @@ -50,6 +51,7 @@

import io.takari.incrementalbuild.Incremental;
import io.takari.incrementalbuild.Incremental.Configuration;
import io.takari.incrementalbuild.MessageSeverity;
import io.takari.incrementalbuild.ResourceMetadata;
import io.takari.maven.plugins.compile.javac.CompilerJavacLauncher;
import io.takari.maven.plugins.exportpackage.ExportPackageMojo;
Expand Down Expand Up @@ -222,6 +224,15 @@ public static enum Sourcepath {
@Parameter(defaultValue = "ignore")
private AccessRulesViolation privatePackageReference;

/**
* Sets "unused dependency declaration" policy violation action
* <p>
* If {@code error}, any dependencies declared in project pom.xml file must be referenced. A lack of reference to any declared dependency will result in compilation errors. If {@code ignore} (the
* default) declaring dependencies without referencing them is allowed.
*/
@Parameter(defaultValue = "ignore")
private AccessRulesViolation unusedDeclaredDependency;

/**
* Controls compilation sourcepath. If set to {@code disable}, compilation sourcepath will be empty. If set to {@code reactorProjects}, compilation sourcepath will be set to compile source roots (or
* test compile source roots) of dependency projects of the same reactor build. The default is {@code reactorProjects} if {@code proc=only}, otherwise the default is {@code disable}.
Expand Down Expand Up @@ -378,7 +389,8 @@ public void execute() throws MojoExecutionException, MojoFailureException {
mkdirs(getOutputDirectory());
}

final List<File> classpath = getClasspath();
final Map<File, Artifact> classpathMap = getClasspath();
final List<File> classpath = new ArrayList<>(classpathMap.keySet());
final List<File> processorpath = getProcessorpath();
Proc proc = getEffectiveProc(classpath, processorpath);

Expand Down Expand Up @@ -419,6 +431,9 @@ public void execute() throws MojoExecutionException, MojoFailureException {
log.info("Compiling {} sources to {}", sources.size(), getOutputDirectory());
int compiled = compiler.compile();
log.info("Compiled {} out of {} sources ({} ms)", compiled, sources.size(), stopwatch.elapsed(TimeUnit.MILLISECONDS));
if (unusedDeclaredDependency != AccessRulesViolation.ignore && context.isEscalated()) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

-1

&& context.isEscalated() here will result in errors reported during full build "magically" disappear during incremental build, only to reappear during the next full build or when modifying some sources but not others.

We need to find a way to make unusedDeclaredDependency=error work during incremental build. I think the only way to do this is to track dependencies used by each source, record this information in build context and use it during subsequent incremental builds. I don't know how easy this is to implement.

Do you see any other way to correctly support incremental build?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True. I did not consider that scenario, and I agree with what you said. I'll remove this change.

I do not see another way from what you detailed. Since there is a need of persistence of the set of dependencies referenced between builds, I believe to keep that set of dependencies accurate, each source's list of dependencies would need to be tracked.
Along those lines, I'm wondering if something like a Map<Path, Set<Artifact>> where at the end of each build, any sources that were built would update the map would work (or the reverse with each dependency listing each source that references it where any that are still empty by the end of the build are considered unused).

checkUnusedDependencies(compiler.getReferencedClasspathEntries(), classpathMap);
}
} else {
compiler.skipCompile();
log.info("Skipped compilation, all {} classes are up to date", sources.size());
Expand All @@ -433,18 +448,53 @@ public void execute() throws MojoExecutionException, MojoFailureException {
}
}

private void checkUnusedDependencies(Set<File> referencedEntries, Map<File, Artifact> classpathMap) throws MojoExecutionException, IOException {
Set<Artifact> referencedArtifacts = new LinkedHashSet<>();
Set<Artifact> unusedArtifacts = new LinkedHashSet<>();
List<File> processorPath = getProcessorpath();

// Include processor path in referenced entries
Set<File> allReferencedEntries = new LinkedHashSet<>();
allReferencedEntries.addAll(referencedEntries);
if (processorPath != null) {
allReferencedEntries.addAll(processorPath);
}

// Find the equivalent artifact equivalent for each referenced entry
for (File entry : allReferencedEntries) {
Artifact artifact = classpathMap.get(entry);
if (artifact != null) {
referencedArtifacts.add(artifact);
}
}

if (directDependencies != null) {
// Check each direct dependency for existence in referenced artifacts
for (Artifact dependency : directDependencies) {
if (!referencedArtifacts.contains(dependency)) {
unusedArtifacts.add(dependency);
}
}
}

// Fail the build for any direct dependencies that are never referenced
if (!unusedArtifacts.isEmpty()) {
context.addPomMessage("The following dependencies are declared but are not used: " + unusedArtifacts, MessageSeverity.ERROR, null);
}
}

private static Set<File> toFileSet(Set<String> paths) {
Set<File> files = new LinkedHashSet<>();
paths.forEach(path -> files.add(new File(path)));
return files;
}

private List<File> getClasspath() {
List<File> classpath = new ArrayList<File>();
private Map<File, Artifact> getClasspath() {
Map<File, Artifact> classpath = new LinkedHashMap<>();
for (Artifact artifact : getClasspathArtifacts()) {
File file = artifact.getFile();
if (file != null) {
classpath.add(file);
classpath.put(file, artifact);
}
}
return classpath;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,8 @@ protected boolean isShowWarnings() {

public abstract int compile() throws MojoExecutionException, IOException;

protected abstract Set<File> getReferencedClasspathEntries();

public void skipCompile() {
context.markUptodateExecution();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,12 @@ public final int compile() throws MojoExecutionException, IOException {
return compile(files);
}

@Override
protected Set<File> getReferencedClasspathEntries() {
String msg = String.format("Compiler %s does not support unusedDeclaredDependency=error, use compilerId=%s", getCompilerId(), CompilerJdt.ID);
throw new UnsupportedOperationException(msg);
}

protected abstract int compile(Map<File, Resource<File>> sources) throws MojoExecutionException, IOException;

protected abstract String getCompilerId();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package io.takari.maven.plugins.compile.jdt;

import java.nio.file.Path;
import java.util.Collection;

import org.eclipse.jdt.core.compiler.IProblem;
Expand Down Expand Up @@ -39,6 +40,11 @@ public String getEntryDescription() {
return sb.toString();
}

@Override
public Path getLocation() {
return entry.getLocation();
}

public static AccessRestrictionClasspathEntry forbidAll(DependencyClasspathEntry entry) {
AccessRule accessRule = new AccessRule(null /* pattern */, IProblem.ForbiddenReference, true /* keep looking for accessible type */);
AccessRestriction accessRestriction = new AccessRestriction(accessRule, AccessRestriction.COMMAND_LINE, entry.getEntryName());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;

import javax.inject.Inject;
import javax.inject.Named;
Expand Down Expand Up @@ -144,6 +145,8 @@ public class CompilerJdt extends AbstractCompiler implements ICompilerRequestor

private final Map<File, ResourceMetadata<File>> sources = new LinkedHashMap<>();

private Set<Path> referencedClasspathEntries = new LinkedHashSet<>();

/**
* Set of ICompilationUnit to be compiled.
*/
Expand Down Expand Up @@ -719,6 +722,7 @@ protected synchronized void addCompilationUnit(ICompilationUnit sourceUnit, Comp
compiler.options.storeAnnotations = true;
}

referencedClasspathEntries = namingEnvironment.getReferencedEntries();
return strategy.compile(namingEnvironment, compiler);
} finally {
if (fileManager != null) {
Expand Down Expand Up @@ -987,4 +991,18 @@ public void skipCompile() {
strategy.skipCompile();
super.skipCompile();
}

@Override
public Set<File> getReferencedClasspathEntries() {
return referencedClasspathEntries.stream().map(this::pathToFile).filter(file -> file != null).collect(Collectors.toSet());
}

private File pathToFile(Path path) {
try {
return path.toFile();
} catch (UnsupportedOperationException e) {
// Java 9 support
return null;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This does not feel right, to be honest. I think it will be cleaner to remember classpath dependencies as Set<Path> and filter referenced dependencies based on membership in the set.

}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -69,4 +69,9 @@ public String toString() {
public String getEntryDescription() {
return directory.getAbsolutePath();
}

@Override
public Path getLocation() {
return directory.toPath();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,11 @@
*/
package io.takari.maven.plugins.compile.jdt.classpath;

import java.nio.file.Path;
import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;

import org.eclipse.jdt.core.compiler.CharOperation;
import org.eclipse.jdt.internal.compiler.env.INameEnvironment;
Expand All @@ -26,10 +29,13 @@ public class Classpath implements INameEnvironment {

private Multimap<String, ClasspathEntry> packages;

private Set<Path> referencedentries;

public Classpath(List<ClasspathEntry> entries, List<MutableClasspathEntry> localentries) {
this.entries = entries;
this.mutableentries = localentries;
this.packages = newPackageIndex(entries);
this.referencedentries = new LinkedHashSet<>();
}

private static Multimap<String, ClasspathEntry> newPackageIndex(List<ClasspathEntry> entries) {
Expand Down Expand Up @@ -58,14 +64,17 @@ public NameEnvironmentAnswer findType(char[] typeName, char[][] packageName) {
}

private NameEnvironmentAnswer findType(String packageName, String typeName) {
Path used = null;
NameEnvironmentAnswer suggestedAnswer = null;
Collection<ClasspathEntry> entries = !packageName.isEmpty() ? packages.get(packageName) : this.entries;
if (entries != null) {
for (ClasspathEntry entry : entries) {
NameEnvironmentAnswer answer = entry.findType(packageName, typeName);
if (answer != null) {
used = entry.getLocation();
if (!answer.ignoreIfBetter()) {
if (answer.isBetter(suggestedAnswer)) {
referencedentries.add(used);
return answer;
}
} else if (answer.isBetter(suggestedAnswer)) {
Expand All @@ -75,6 +84,9 @@ private NameEnvironmentAnswer findType(String packageName, String typeName) {
}
}
}
if (used != null) {
referencedentries.add(used);
}
return suggestedAnswer;
}

Expand Down Expand Up @@ -102,4 +114,8 @@ public void reset() {
public List<ClasspathEntry> getEntries() {
return entries;
}

public Set<Path> getReferencedEntries() {
return referencedentries;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/
package io.takari.maven.plugins.compile.jdt.classpath;

import java.nio.file.Path;
import java.util.Collection;

import org.eclipse.jdt.internal.compiler.env.NameEnvironmentAnswer;
Expand All @@ -18,4 +19,6 @@ public interface ClasspathEntry {
NameEnvironmentAnswer findType(String packageName, String typeName);

String getEntryDescription();

Path getLocation();
}
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,11 @@ public String getEntryDescription() {
return sb.toString();
}

@Override
public Path getLocation() {
return file;
}

@Override
public NameEnvironmentAnswer findType(String packageName, String typeName) {
return findType(packageName, typeName, getAccessRestriction(packageName));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -374,4 +374,74 @@ public void testInnerTypeDependency_sourceDependencies() throws Exception {
mojos.compile(project, newParameter("dependencySourceTypes", "prefer"));
mojos.assertBuildOutputs(new File(basedir, "target/classes"), "innertyperef/InnerTypeRef.class");
}

@Test
public void testReferencedEntries() throws Exception {
final String javacMessage = "Compiler javac does not support unusedDeclaredDependency=error, use compilerId=jdt";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use Assume.assumeTrue(CompilerJdt.ID.equals(compilerId)); to only run the test with JDT compiler backend. Validating javac error message pollutes the test and makes it more difficult to understand.

File parent = resources.getBasedir("compile/referenced-entries");
Xpp3Dom unused = new Xpp3Dom("unusedDeclaredDependency");
unused.setValue("error");

mojos.flushClasspathCaches();

File b = new File(parent, "b");

try {
mojos.compile(b, unused);
} catch (UnsupportedOperationException e) {
ErrorMessage.isMatch(e.getMessage(), javacMessage);
}

MavenProject a = mojos.readMavenProject(new File(parent, "a"));
addDependency(a, "b", new File(b, "target/classes"));

try {
mojos.compile(a, unused);
mojos.assertBuildOutputs(parent, "a/target/classes/a/A1.class", "a/target/classes/a/A2.class");
} catch (UnsupportedOperationException e) {
ErrorMessage.isMatch(e.getMessage(), javacMessage);
}

cp(new File(parent, "a/src/main/java/a"), "A2.java-method", "A2.java");

try {
mojos.compile(a, unused);
// only A2 should have new output
mojos.assertBuildOutputs(parent, "a/target/classes/a/A2.class");
} catch (UnsupportedOperationException e) {
ErrorMessage.isMatch(e.getMessage(), javacMessage);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think for completeness sake this test should also remove a.A1->b.B dependency and assert that only a.A1 got recompiled and that unused dependency error was reported.

}

@Test
public void testReactorUnused() throws Exception {
final String javacMessage = "Compiler javac does not support unusedDeclaredDependency=error, use compilerId=jdt";
ErrorMessage expected = new ErrorMessage(compilerId);
expected.setSnippets("jdt", "ERROR pom.xml [0:0] The following dependencies are declared but are not used: [test:module-b:jar:1.0:compile]");
File parent = resources.getBasedir("compile/reactor-unused");
Xpp3Dom unused = new Xpp3Dom("unusedDeclaredDependency");
unused.setValue("error");

mojos.flushClasspathCaches();

File moduleB = new File(parent, "module-b");

try {
mojos.compile(moduleB, unused);
} catch (UnsupportedOperationException e) {
ErrorMessage.isMatch(e.getMessage(), javacMessage);
}

MavenProject moduleA = mojos.readMavenProject(new File(parent, "module-a"));
addDependency(moduleA, "module-b", new File(moduleB, "target/classes"));

try {
mojos.compile(moduleA, unused);
Assert.fail();
} catch (MojoExecutionException e) {
mojos.assertMessage(parent, "module-a/pom.xml", expected);
} catch (UnsupportedOperationException e) {
ErrorMessage.isMatch(e.getMessage(), javacMessage);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<groupId>reactor</groupId>
<artifactId>module-a</artifactId>
<version>1.0.0-SNAPSHOT</version>
<packaging>jar</packaging>

<dependencies>
<dependency>
<groupId>reactor</groupId>
<artifactId>module-b</artifactId>
<version>1.0.0-SNAPSHOT</version>
</dependency>
</dependencies>
</project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package reactor.modulea;

public class ModuleA {

}
Loading