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

Conversation

betafood
Copy link

This adds tracking on what dependencies are referenced in the jdt compiler.

Copy link
Contributor

@ifedorenko ifedorenko left a comment

Choose a reason for hiding this comment

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

Thank you for the contribution, Ben. I think this is a useful addition to takari-lifecycle and agree with implementation approach. There are few code-polish issues, but nothing major. Let me know if you have any questions.

@@ -273,6 +273,11 @@ public final int compile() throws MojoExecutionException, IOException {
return compile(files);
}

@Override
public Set<String> getReferencedClasspathEntries() {
Copy link
Contributor

Choose a reason for hiding this comment

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

Don't silently ignore unsupported policy value, make it clear to the users that unusedDeclaredDependencies=error only works with JDT-based compiler backend. See how this is done for transitiveDependencyReference and privatePackageReference (or just move unusedDeclaredDependency to CompilerJdt)

List<File> processorpath = getProcessorpath();

// Find the equivalent artifact equivalent for each referenced entry
for (String entry : referencedEntries) {
Copy link
Contributor

Choose a reason for hiding this comment

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

This is N^2 loop and needs optimization. For a project with 3000 dependencies it'll run 9000000 (9 million) iterations. I'd change #getClasspath() to return Map<File,Artifact> and then do single for-each referencedEntries to match entries to classpath artifacts.

if (unusedDeclaredDependency == AccessRulesViolation.error) {
context.addPomMessage("The following dependencies are declared but are not used: " + unusedArtifacts, MessageSeverity.ERROR, null);
} else {
log.warn(pom.getAbsolutePath() + ": The following dependencies are declared but are not used: {}", unusedArtifacts);
Copy link
Contributor

Choose a reason for hiding this comment

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

ignore means ignore, don't log anything here. actually, don't execute the check at all if unusedDeclaredDependency=ignore.

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.getEntryDescription();
Copy link
Contributor

Choose a reason for hiding this comment

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

entry.getEntryDescription() is for humans to read, introduce new Path entry.getLocation() to get classpath entry location for further processing.

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

public Set<String> getReferencedEntries() {
Copy link
Contributor

Choose a reason for hiding this comment

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

Should return Set<Path>

mojos.compile(moduleA, unused);
fail();
} catch (MojoExecutionException e) {
assertTrue(e.getLocalizedMessage().contains("The following dependencies are declared but are not used: [test:module-b:jar:1.0:compile]"));
Copy link
Contributor

Choose a reason for hiding this comment

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

Don't assert the exception, use CompileRule.assertMessages to assert expected message was created on the pom file.

@@ -0,0 +1,5 @@
package reactor.moduleb;
Copy link
Contributor

Choose a reason for hiding this comment

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

remove unused test file

@@ -0,0 +1,5 @@
package reactor.moduleb;
Copy link
Contributor

Choose a reason for hiding this comment

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

remove unused test file

@@ -0,0 +1,8 @@
package reactor.moduleb;
Copy link
Contributor

Choose a reason for hiding this comment

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

remove unused test file

@@ -0,0 +1,14 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
Copy link
Contributor

Choose a reason for hiding this comment

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

remove unused test file

Copy link
Author

@betafood betafood left a comment

Choose a reason for hiding this comment

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

Thanks for all the comments. I've made a new commit to address them. I also added a few questions here.

@@ -117,7 +115,7 @@ public void reset() {
return entries;
}

public Set<String> getReferencedEntries() {
public Set<Path> getReferencedEntries() {
return referencedentries;
Copy link
Author

Choose a reason for hiding this comment

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

Would it make sense to use File here to have parity with File in AbstractCompiler?

Copy link
Contributor

Choose a reason for hiding this comment

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

Currently Classpath uses Path, and CompilerJdt translates between Path and File. Beware, however, that under java 9 system libraries will not have corresponding Files

undeclaredArtifacts.add(artifact);
}
}
}

// Log or fail the build for any referenced artifacts that are not declared as a direct dependency
// Fail the build for any referenced artifacts that are not declared as a direct dependency
if (!undeclaredArtifacts.isEmpty()) {
if (unusedDeclaredDependency == AccessRulesViolation.error) {
Copy link
Author

Choose a reason for hiding this comment

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

Looking at this again. Would it make sense to have a separate AccessRulesViolation for "undeclared" vs "unused" artifacts? To fail only on undeclared or unused rather than failing on any combination of the two. Although, I guess technically speaking, "undeclared" is almost the same thing as transitiveDependencyReference.

Copy link
Contributor

Choose a reason for hiding this comment

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

You are right, I missed it.

checkDependencyReferences should really be checkUnusedDependencies and only check for unused dependencies declared in project pom.xml (or inherited from the parent).

Existing transitiveDependencyReference policy already covers references to transitive/undeclared dependencies. No need duplicate functionality. I'll update review in the code too.


// Find the equivalent artifact equivalent for each referenced entry
for (File entry : allReferencedEntries) {
Artifact artifact = getClasspath().get(entry);
Copy link
Contributor

Choose a reason for hiding this comment

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

This unnecessary calculates new classpath map for each referenced entry. At least pull #getClasspath() out of the loop. Better yet, pass classpath map as a parameter, so the same map is used during compilation and when checking for unused dependencies. (which will also ensure the two classpaths are the same)


// Fail the build for any referenced artifacts that are not declared as a direct dependency
if (!undeclaredArtifacts.isEmpty()) {
if (unusedDeclaredDependency == AccessRulesViolation.error) {
Copy link
Contributor

Choose a reason for hiding this comment

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

unusedDeclaredDependency == AccessRulesViolation.error is redundant here.

Copy link
Contributor

Choose a reason for hiding this comment

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

Actually, remove this branch altogether. transitiveDependencyReference policy already provides mechanism to block use of transitive dependencies, and that mechanism also provides more targeted error messages.

private List<File> getClasspath() {
List<File> classpath = new ArrayList<File>();
private Map<File, Artifact> getClasspath() {
Map<File, Artifact> classpath = new LinkedHashMap<File, Artifact>();
Copy link
Contributor

Choose a reason for hiding this comment

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

Please use diamond operator.

@@ -121,6 +122,11 @@ public String getEntryDescription() {
return sb.toString();
}

@Override
public Path getLocation() {
return file.toPath();
Copy link
Contributor

Choose a reason for hiding this comment

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

What branch are you using? #file has type Path in the current master.

Copy link
Author

Choose a reason for hiding this comment

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

My fork is 5 commits behind master, so I'll have to rebase the branch.

@@ -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 && compiler.getReferencedClasspathEntries() != null && !compiler.getReferencedClasspathEntries().isEmpty()) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Check compiler.getReferencedClasspathEntries() != null && !compiler.getReferencedClasspathEntries().isEmpty() is redundant here.


// Fail the build for any direct dependencies that are never referenced
if (!unusedArtifacts.isEmpty()) {
if (unusedDeclaredDependency == AccessRulesViolation.error) {
Copy link
Contributor

Choose a reason for hiding this comment

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

unusedDeclaredDependency == AccessRulesViolation.error is redundant here.

@@ -59,6 +61,8 @@

private AccessRulesViolation privatePackageReference;

private AccessRulesViolation unusedDeclaredDependency;
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 unusedDeclaredDependency and corresponding getter are redundant.

this.unusedDeclaredDependency = unusedDeclaredDependency;
}

protected Set<File> getReferencedClasspathEntries() {
Copy link
Contributor

Choose a reason for hiding this comment

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

I'd define getReferencedClasspathEntries() as abstract here and throw UnsupportedOperationException in Javac compiler backend.

@@ -433,18 +448,70 @@ public void execute() throws MojoExecutionException, MojoFailureException {
}
}

private void checkDependencyReferences(Set<File> referencedEntries) throws MojoExecutionException, IOException {
Copy link
Contributor

Choose a reason for hiding this comment

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

Should be called checkUnusedDependencies.


// Fail the build for any referenced artifacts that are not declared as a direct dependency
if (!undeclaredArtifacts.isEmpty()) {
if (unusedDeclaredDependency == AccessRulesViolation.error) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Actually, remove this branch altogether. transitiveDependencyReference policy already provides mechanism to block use of transitive dependencies, and that mechanism also provides more targeted error messages.

@betafood
Copy link
Author

Thanks for the quick review and answers to my questions. Added another commit to address your comments(along with rebasing the branch on the latest master).

@ifedorenko
Copy link
Contributor

This change fails when running the build under java 9. I haven't looked closely, but suspect CompilerJdt#getReferencedClasspathEntries() needs to disregard references to jdk system libraries. In other words, it should only return files passed to the compiler via #setClasspath(...) and ignore anything else. Note that you need to rebase on latest master to be able to build with java 9.

I also would like to see an automated test for the following scenario:

  • two projects, project-a and project-b, project-a depends on project-b;
  • project-b has single class b.B
  • project-a has two classes, a.A1 and a.A2, class a.A1 references class b.B from project-b.
  • initial full compile of project-a is a success
  • a.A2 is changed is some way (just don't add dependency on b.B)
  • incremental compile of project-a is success, and only a.A2 is recompiled.

Benjamin added 5 commits January 29, 2018 01:22
* refactor checking logic to remove n^2 loop
* setting unusedDeclaredDependency to ignore skips check logic
* use Path & File over String for referenced entries
* Move test to CompileTest
* Remove unused test files
* removed undeclared dependency checking
* remove redundant checking, calls, and methods
@betafood
Copy link
Author

modules/java.base is coming in as a JrtPath which toFile throws an UnsupportedOperationException when called. Within getReferencedEntries, the toFile calls were changed to return null on UnsupportedOperationException and then an additional filter was called to filter out any null values.

I also made a change for unusedDeclaredDependencies to only check in a full build context. Writing the test you detailed had it failing for unused declared dependencies for incremental builds(since the classes that did reference the dependency did not have to be built again).

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.


@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.

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.

@@ -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).

@ifedorenko
Copy link
Contributor

Here is another incremental build test I'd like to see

  • a project has two differently named but otherwise identical dependencies. project sources use classpath from the dependencies. the second dependency is marked as unused
  • the first dependency is removed. incremental build is run.
  • the correct behaviour is to remove unused error from now the only project dependency, but I suspect the errors will not be removed

Compiler uses observable class features (like members or class modifiers) to determine how what project sources needs to be recompiled after classpath dependency change. Removing redundant dependency does not change set of available types, so nothing is recompiled. Moving classes from one dependency to another will have the same effect.

I don't have a good proposal how to solve this.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

2 participants