Repository: CycloneDX/cyclonedx-maven-plugin (java)

Issue:
Add support for generating optionality based on the maven dependency configuration
The current codebase derives the scope of a component through bytecode analysis, however this has problems in that it
- differs from maven's view of what is optional (non-intuitive)
- can change depending on which projects have been built (classes need to exist)
- doesn't handle artifact relocations

We would like the ability to define optionality based on whether maven determines the artifact to be optional, i.e. through the dependency resolution process rather than bytecode inspection.

@hboutemy I have something working for this, just waiting on the conclusion of #306 and for other PRs to be submitted.

@norrisjeremy FYI





Pull request description:
Add support for maven optionality, fixes #314

Patch:
diff --git a/pom.xml b/pom.xml
index b4ac034..e6113c5 100644
--- a/pom.xml
+++ b/pom.xml
@@ -145,7 +145,7 @@
         <dependency>
             <groupId>org.apache.maven.shared</groupId>
             <artifactId>maven-dependency-analyzer</artifactId>
-            <version>1.13.2</version>
+            <version>1.13.1</version>
             <exclusions>
                 <exclusion>
                     <groupId>org.apache.maven</groupId>
diff --git a/src/it/makeAggregateBom/verify.groovy b/src/it/makeAggregateBom/verify.groovy
index 4f0fa88..0bf9481 100644
--- a/src/it/makeAggregateBom/verify.groovy
+++ b/src/it/makeAggregateBom/verify.groovy
@@ -8,6 +8,7 @@ void assertBomFiles(String path, boolean aggregate) {
     String analysis = aggregate ? "makeAggregateBom" : "makeBom"
     assert bomFileXml.text.contains('<property name="maven.goal">' + analysis + '</property>')
     assert bomFileXml.text.contains('<property name="maven.scopes">compile,provided,runtime,system</property>')
+    assert bomFileXml.text.contains('<property name="maven.optional">true</property>')
     assert bomFileJson.text.contains('"name" : "maven.goal",')
     assert bomFileJson.text.contains('"value" : "' + analysis + '"')
     assert bomFileJson.text.contains('"name" : "maven.scopes",')
diff --git a/src/it/makeBom/verify.groovy b/src/it/makeBom/verify.groovy
index f7553e1..f5770ad 100644
--- a/src/it/makeBom/verify.groovy
+++ b/src/it/makeBom/verify.groovy
@@ -6,6 +6,8 @@ assert bomFileJson.exists()
 
 assert bomFileXml.text.contains('<reference type="website"><url>https://github.com/CycloneDX/cyclonedx-maven-plugin</url></reference>')
 
+assert bomFileXml.text.contains('<property name="maven.optional">true</property>')
+
 // Reproducible Builds
 assert !bomFileJson.text.contains('"serialNumber"')
 assert !bomFileJson.text.contains('"timestamp"')
diff --git a/src/main/java/org/cyclonedx/maven/BaseCycloneDxMojo.java b/src/main/java/org/cyclonedx/maven/BaseCycloneDxMojo.java
index b6369f6..b2257e6 100644
--- a/src/main/java/org/cyclonedx/maven/BaseCycloneDxMojo.java
+++ b/src/main/java/org/cyclonedx/maven/BaseCycloneDxMojo.java
@@ -167,6 +167,15 @@ public abstract class BaseCycloneDxMojo extends AbstractMojo {
     @Parameter(property = "excludeTypes", required = false)
     private String[] excludeTypes;
 
+    /**
+     * Use the original mechanism for determining whether an artifact is OPTIONAL/REQUIRED, relying on bytecode analysis
+     * of the compiled classes instead of the maven declaration of optional.
+     *
+     * @since 2.7.9
+     */
+    @Parameter(property = "detectUnusedForOptionalScope", defaultValue = "false")
+    protected boolean detectUnusedForOptionalScope;
+
     @org.apache.maven.plugins.annotations.Component(hint = "default")
     private RepositorySystem aetherRepositorySystem;
 
@@ -280,6 +289,8 @@ public abstract class BaseCycloneDxMojo extends AbstractMojo {
                 if (includeSystemScope) scopes.add("system");
                 if (includeTestScope) scopes.add("test");
                 metadata.addProperty(newProperty("maven.scopes", String.join(",", scopes)));
+
+                metadata.addProperty(newProperty("maven.optional", Boolean.toString(!detectUnusedForOptionalScope)));
             }
 
             final Component rootComponent = metadata.getComponent();
@@ -426,7 +437,7 @@ public abstract class BaseCycloneDxMojo extends AbstractMojo {
         for (Map.Entry<String, Artifact> entry: artifacts.entrySet()) {
             final String purl = entry.getKey();
             final Artifact artifact = entry.getValue();
-            final Component.Scope artifactScope = (dependencyAnalysis != null ? inferComponentScope(artifact, dependencyAnalysis) : null);
+            final Component.Scope artifactScope = getComponentScope(artifact, dependencyAnalysis);
             final Component component = components.get(purl);
             if (component == null) {
                 final Component newComponent = convert(artifact);
@@ -438,6 +449,25 @@ public abstract class BaseCycloneDxMojo extends AbstractMojo {
         }
     }
 
+    /**
+     * Get the BOM component scope (required/optional/excluded).  The scope can either be determined through bytecode
+     * analysis or through maven dependency resolution.
+     *
+     * @param artifact Artifact from maven project
+     * @param projectDependencyAnalysis Maven Project Dependency Analysis data
+     *
+     * @return Component.Scope - REQUIRED, OPTIONAL or null if it cannot be determined
+     *
+     * @see detectUnusedForOptionalScope
+     */
+    private Component.Scope getComponentScope(Artifact artifact, ProjectDependencyAnalysis projectDependencyAnalysis) {
+        if (detectUnusedForOptionalScope) {
+            return inferComponentScope(artifact, projectDependencyAnalysis);
+        } else {
+            return (artifact.isOptional() ? Component.Scope.OPTIONAL : Component.Scope.REQUIRED);
+        }
+    }
+
     /**
      * Infer BOM component scope (required/optional/excluded) based on Maven project dependency analysis.
      *
@@ -446,7 +476,7 @@ public abstract class BaseCycloneDxMojo extends AbstractMojo {
      *
      * @return Component.Scope - REQUIRED: If the component is used (as detected by project dependency analysis). OPTIONAL: If it is unused
      */
-    protected Component.Scope inferComponentScope(Artifact artifact, ProjectDependencyAnalysis projectDependencyAnalysis) {
+    private Component.Scope inferComponentScope(Artifact artifact, ProjectDependencyAnalysis projectDependencyAnalysis) {
         if (projectDependencyAnalysis == null) {
             return null;
         }
diff --git a/src/main/java/org/cyclonedx/maven/CycloneDxMojo.java b/src/main/java/org/cyclonedx/maven/CycloneDxMojo.java
index 83294d3..66db0ea 100644
--- a/src/main/java/org/cyclonedx/maven/CycloneDxMojo.java
+++ b/src/main/java/org/cyclonedx/maven/CycloneDxMojo.java
@@ -83,13 +83,15 @@ public class CycloneDxMojo extends BaseCycloneDxMojo {
     }
 
     protected ProjectDependencyAnalysis doProjectDependencyAnalysis(final MavenProject mavenProject, final BomDependencies bomDependencies) throws MojoExecutionException {
-        final MavenProject localMavenProject = new MavenProject(mavenProject);
-        localMavenProject.setArtifacts(new LinkedHashSet<>(bomDependencies.getArtifacts().values()));
-        localMavenProject.setDependencyArtifacts(new LinkedHashSet<>(bomDependencies.getDependencyArtifacts().values()));
-        try {
-            return getProjectDependencyAnalyzer().analyze(localMavenProject);
-        } catch (ProjectDependencyAnalyzerException pdae) {
-            getLog().debug("Could not analyze " + mavenProject.getId(), pdae); // TODO should warn...
+        if (detectUnusedForOptionalScope) {
+            final MavenProject localMavenProject = new MavenProject(mavenProject);
+            localMavenProject.setArtifacts(new LinkedHashSet<>(bomDependencies.getArtifacts().values()));
+            localMavenProject.setDependencyArtifacts(new LinkedHashSet<>(bomDependencies.getDependencyArtifacts().values()));
+            try {
+                return getProjectDependencyAnalyzer().analyze(localMavenProject);
+            } catch (ProjectDependencyAnalyzerException pdae) {
+                getLog().debug("Could not analyze " + mavenProject.getId(), pdae); // TODO should warn...
+            }
         }
         return null;
     }


Test patch:
diff --git a/src/test/java/org/cyclonedx/maven/BaseMavenVerifier.java b/src/test/java/org/cyclonedx/maven/BaseMavenVerifier.java
index c3dd951..49283c7 100644
--- a/src/test/java/org/cyclonedx/maven/BaseMavenVerifier.java
+++ b/src/test/java/org/cyclonedx/maven/BaseMavenVerifier.java
@@ -10,6 +10,7 @@ import java.io.File;
 import java.io.FileInputStream;
 import java.io.IOException;
 import java.io.InputStreamReader;
+import java.util.Map;
 import java.util.Properties;
 import org.junit.Rule;
 
@@ -55,10 +56,14 @@ public class BaseMavenVerifier {
     }
 
     protected File cleanAndBuild(final String project, final String[] excludeTypes, final String[] profiles) throws Exception {
-        return mvnBuild(project, null, excludeTypes, null);
+        return mvnBuild(project, null, excludeTypes, profiles, null);
     }
 
-    protected File mvnBuild(final String project, final String[] goals, final String[] excludeTypes, final String[] profiles) throws Exception {
+    protected File cleanAndBuild(final String project, final Map<String, String> properties, final String[] excludeTypes) throws Exception {
+        return mvnBuild(project, null, excludeTypes, null, properties);
+    }
+
+    protected File mvnBuild(final String project, final String[] goals, final String[] excludeTypes, final String[] profiles, final Map<String, String> properties) throws Exception {
         File projDir = resources.getBasedir(project);
 
         MavenExecution execution = verifier
@@ -70,7 +75,12 @@ public class BaseMavenVerifier {
             execution = execution.withCliOption("-DexcludeTypes=" + String.join(",", excludeTypes));
         }
         if ((profiles != null) && (profiles.length > 0)) {
-            execution = execution.withCliOption("-P" + String.join(",", profiles));
+            execution.withCliOption("-P" + String.join(",", profiles));
+        }
+        if (properties != null) {
+            for (Map.Entry<String, String> entry: properties.entrySet()) {
+                execution.withCliOption("-D" + entry.getKey() + "=" + entry.getValue());
+            }
         }
         if (goals != null && goals.length > 0) {
             execution.execute(goals).assertErrorFreeLog();
diff --git a/src/test/java/org/cyclonedx/maven/CyclicTest.java b/src/test/java/org/cyclonedx/maven/CyclicTest.java
index c0dd422..fe7d790 100644
--- a/src/test/java/org/cyclonedx/maven/CyclicTest.java
+++ b/src/test/java/org/cyclonedx/maven/CyclicTest.java
@@ -43,7 +43,7 @@ public class CyclicTest extends BaseMavenVerifier {
         cleanAndBuild("cyclic", null);
         File projDir = null;
         try {
-            projDir = mvnBuild("cyclic", new String[]{"package"}, null, new String[] {"profile"});
+            projDir = mvnBuild("cyclic", new String[]{"package"}, null, new String[] {"profile"}, null);
         } catch (final Exception ex) {
             fail("Failed to generate SBOM", ex);
         }
diff --git a/src/test/java/org/cyclonedx/maven/Issue311Test.java b/src/test/java/org/cyclonedx/maven/Issue311Test.java
index d72e9ff..56f78bd 100644
--- a/src/test/java/org/cyclonedx/maven/Issue311Test.java
+++ b/src/test/java/org/cyclonedx/maven/Issue311Test.java
@@ -39,7 +39,7 @@ public class Issue311Test extends BaseMavenVerifier {
 
     @Test
     public void testLatestAndRelease() throws Exception {
-        final File projDir = mvnBuild("issue-311", new String[]{"clean", "install"}, null, null);
+        final File projDir = mvnBuild("issue-311", new String[]{"clean", "install"}, null, null, null);
 
         checkLatest(projDir);
         checkRelease(projDir);
diff --git a/src/test/java/org/cyclonedx/maven/Issue314Test.java b/src/test/java/org/cyclonedx/maven/Issue314Test.java
new file mode 100644
index 0000000..e3c0c2b
--- /dev/null
+++ b/src/test/java/org/cyclonedx/maven/Issue314Test.java
@@ -0,0 +1,112 @@
+package org.cyclonedx.maven;
+
+import java.io.File;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.cyclonedx.maven.TestUtils.getComponentNode;
+import static org.cyclonedx.maven.TestUtils.getElement;
+import static org.cyclonedx.maven.TestUtils.readXML;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+
+import org.cyclonedx.model.Component;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+import org.w3c.dom.NodeList;
+
+import io.takari.maven.testing.executor.MavenRuntime.MavenRuntimeBuilder;
+import io.takari.maven.testing.executor.MavenVersions;
+import io.takari.maven.testing.executor.junit.MavenJUnitTestRunner;
+
+/**
+ * Fix BOM handling of conflicting dependency tree graphs
+ */
+@RunWith(MavenJUnitTestRunner.class)
+@MavenVersions({"3.6.3"})
+public class Issue314Test extends BaseMavenVerifier {
+
+    private static final String ISSUE_314_DEPENDENCY_B = "pkg:maven/com.example.issue_314/dependency_B@1.0.0?type=jar";
+    private static final String ISSUE_314_DEPENDENCY_C = "pkg:maven/com.example.issue_314/dependency_C@1.0.0?type=jar";
+    private static final String ISSUE_314_DEPENDENCY_D = "pkg:maven/com.example.issue_314/dependency_D@1.0.0?type=jar";
+
+    public Issue314Test(MavenRuntimeBuilder runtimeBuilder) throws Exception {
+        super(runtimeBuilder);
+    }
+
+    /**
+     * Validate the bytecode analysis components.
+     * - No component should be marked as optional
+     */
+    @Test
+    public void testBytecodeDependencyTree() throws Exception {
+      final Map<String, String> properties = new HashMap<>();
+      properties.put("detectUnusedForOptionalScope", "true");
+      final File projDir = mvnBuild("issue-314", null, null, null, properties);
+
+      final String requiredName = Component.Scope.REQUIRED.getScopeName();
+
+      final Document bom = readXML(new File(projDir, "dependency_A/target/bom.xml"));
+
+      final NodeList componentsList = bom.getElementsByTagName("components");
+      assertEquals("Expected a single components element", 1, componentsList.getLength());
+      final Element components = (Element)componentsList.item(0);
+
+      final Element componentBNode = getComponentNode(components, ISSUE_314_DEPENDENCY_B);
+      final Element componentBScope = getElement(componentBNode, "scope");
+      if (componentBScope != null) {
+        assertEquals("dependency_B scope should be " + requiredName, requiredName, componentBScope.getTextContent());
+      }
+
+      final Element componentCNode = getComponentNode(components, ISSUE_314_DEPENDENCY_C);
+      final Element componentCScope = getElement(componentCNode, "scope");
+      if (componentCScope != null) {
+        assertEquals("dependency_C scope should be " + requiredName, requiredName, componentCScope.getTextContent());
+      }
+
+      final Element componentDNode = getComponentNode(components, ISSUE_314_DEPENDENCY_D);
+      final Element componentDScope = getElement(componentDNode, "scope");
+      if (componentDScope != null) {
+        assertEquals("dependency_D scope should be " + requiredName, requiredName, componentDScope.getTextContent());
+      }
+  }
+
+    /**
+     * Validate the maven optional components.
+     * - com.example.issue_314:dependency_C:1.0.0 and com.example.issue_314:dependency_D:1.0.0 *should* be marked as optional
+     */
+    @Test
+    public void testMavenOptionalDependencyTree() throws Exception {
+      final Map<String, String> properties = new HashMap<>();
+      properties.put("detectUnusedForOptionalScope", "false");
+      final File projDir = mvnBuild("issue-314", null, null, null, properties);
+
+      final String requiredName = Component.Scope.REQUIRED.getScopeName();
+      final String optionalName = Component.Scope.OPTIONAL.getScopeName();
+
+      final Document bom = readXML(new File(projDir, "dependency_A/target/bom.xml"));
+
+      final NodeList componentsList = bom.getElementsByTagName("components");
+      assertEquals("Expected a single components element", 1, componentsList.getLength());
+      final Element components = (Element)componentsList.item(0);
+
+      final Element componentBNode = getComponentNode(components, ISSUE_314_DEPENDENCY_B);
+      final Element componentBScope = getElement(componentBNode, "scope");
+      if (componentBScope != null) {
+        assertEquals("dependency_B scope should be " + requiredName, requiredName, componentBScope.getTextContent());
+      }
+
+      final Element componentCNode = getComponentNode(components, ISSUE_314_DEPENDENCY_C);
+      final Element componentCScope = getElement(componentCNode, "scope");
+      assertNotNull("dependency_C is missing its scope", componentCScope);
+      assertEquals("dependency_C scope should be " + optionalName, optionalName, componentCScope.getTextContent());
+
+      final Element componentDNode = getComponentNode(components, ISSUE_314_DEPENDENCY_D);
+      final Element componentDScope = getElement(componentDNode, "scope");
+      assertNotNull("dependency_D is missing its scope", componentDScope);
+      assertEquals("dependency_D scope should be " + optionalName, optionalName, componentDScope.getTextContent());
+  }
+}
diff --git a/src/test/java/org/cyclonedx/maven/TestUtils.java b/src/test/java/org/cyclonedx/maven/TestUtils.java
index bb9731b..0f6cc2e 100644
--- a/src/test/java/org/cyclonedx/maven/TestUtils.java
+++ b/src/test/java/org/cyclonedx/maven/TestUtils.java
@@ -10,20 +10,38 @@ import javax.xml.parsers.DocumentBuilderFactory;
 import javax.xml.parsers.ParserConfigurationException;
 
 import org.w3c.dom.Document;
+import org.w3c.dom.Element;
 import org.w3c.dom.Node;
 import org.w3c.dom.NodeList;
 import org.xml.sax.SAXException;
 
 class TestUtils {
-    static Node getDependencyNode(final Node dependencies, final String ref) {
+    static Element getElement(final Element parent, final String elementName) throws Exception {
+        Element element = null;
+        Node child = parent.getFirstChild();
+        while (child != null) {
+            if (Node.ELEMENT_NODE == child.getNodeType()) {
+                if (child.getNodeName().equals(elementName)) {
+                    if (element != null) {
+                        throw new Exception("Second instance of element " + elementName + " discovered in " + parent.getNodeName());
+                    }
+                    element = (Element)child;
+                }
+            }
+            child = child.getNextSibling();
+        }
+        return element;
+    }
+
+    static Element getDependencyNode(final Node dependencies, final String ref) {
         return getChildElement(dependencies, ref, "dependency", "ref");
     }
 
-    static Node getComponentNode(final Node components, final String ref) {
+    static Element getComponentNode(final Node components, final String ref) {
         return getChildElement(components, ref, "component", "bom-ref");
     }
 
-    private static Node getChildElement(final Node parent, final String ref, final String elementName, final String attrName) {
+    private static Element getChildElement(final Node parent, final String ref, final String elementName, final String attrName) {
         final NodeList children = parent.getChildNodes();
         final int numChildNodes = children.getLength();
         for (int index = 0 ; index < numChildNodes ; index++) {
@@ -31,7 +49,7 @@ class TestUtils {
             if ((child.getNodeType() == Node.ELEMENT_NODE) && elementName.equals(child.getNodeName())) {
                 final Node refNode = child.getAttributes().getNamedItem(attrName);
                 if (ref.equals(refNode.getNodeValue())) {
-                    return child;
+                    return (Element)child;
                 }
             }
         }
diff --git a/src/test/resources/issue-314/dependency_A/pom.xml b/src/test/resources/issue-314/dependency_A/pom.xml
new file mode 100644
index 0000000..27ba849
--- /dev/null
+++ b/src/test/resources/issue-314/dependency_A/pom.xml
@@ -0,0 +1,68 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<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/maven-v4_0_0.xsd">
+
+    <modelVersion>4.0.0</modelVersion>
+
+    <parent>
+        <groupId>com.example.issue_314</groupId>
+        <artifactId>issue_314_parent</artifactId>
+        <version>1.0.0</version>
+    </parent>
+
+    <artifactId>dependency_A</artifactId>
+
+    <name>Dependency A</name>
+
+    <properties>
+        <maven.compiler.target>1.8</maven.compiler.target>
+        <maven.compiler.source>1.8</maven.compiler.source>
+    </properties>
+
+    <dependencies>
+        <dependency>
+            <groupId>com.example.issue_314</groupId>
+            <artifactId>dependency_B</artifactId>
+            <version>1.0.0</version>
+            <scope>compile</scope>
+        </dependency>
+        <dependency>
+            <groupId>com.example.issue_314</groupId>
+            <artifactId>dependency_C</artifactId>
+            <version>1.0.0</version>
+            <scope>provided</scope>
+            <optional>true</optional>
+        </dependency>
+    </dependencies>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.cyclonedx</groupId>
+                <artifactId>cyclonedx-maven-plugin</artifactId>
+                <version>${current.version}</version>
+                <executions>
+                    <execution>
+                        <phase>package</phase>
+                        <goals>
+                            <goal>makeBom</goal>
+                        </goals>
+                    </execution>
+                </executions>
+                <configuration>
+                    <projectType>library</projectType>
+                    <schemaVersion>1.4</schemaVersion>
+                    <includeBomSerialNumber>true</includeBomSerialNumber>
+                    <includeCompileScope>true</includeCompileScope>
+                    <includeProvidedScope>true</includeProvidedScope>
+                    <includeRuntimeScope>false</includeRuntimeScope>
+                    <includeSystemScope>false</includeSystemScope>
+                    <includeTestScope>false</includeTestScope>
+                    <includeLicenseText>false</includeLicenseText>
+                    <outputFormat>xml</outputFormat>
+                </configuration>
+            </plugin>
+        </plugins>
+    </build>
+</project>
diff --git a/src/test/resources/issue-314/dependency_A/src/main/java/com/example/issue_314/liba/LibA.java b/src/test/resources/issue-314/dependency_A/src/main/java/com/example/issue_314/liba/LibA.java
new file mode 100644
index 0000000..0bda872
--- /dev/null
+++ b/src/test/resources/issue-314/dependency_A/src/main/java/com/example/issue_314/liba/LibA.java
@@ -0,0 +1,18 @@
+package com.example.issue_314.liba;
+
+import com.example.issue_314.libb.LibB;
+import com.example.issue_314.libc.LibC;
+
+public class LibA {
+    private LibA() {}
+
+    public static void main(final String[] args) {
+        System.out.println("In libA");
+        LibB.libBMethod();
+        try {
+            LibC.libCMethod();
+        } catch (final NoClassDefFoundError ncdfe) {
+            System.out.println("Optional library libC not present on classpath");
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/test/resources/issue-314/dependency_B/pom.xml b/src/test/resources/issue-314/dependency_B/pom.xml
new file mode 100644
index 0000000..603b8a1
--- /dev/null
+++ b/src/test/resources/issue-314/dependency_B/pom.xml
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<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/maven-v4_0_0.xsd">
+
+    <modelVersion>4.0.0</modelVersion>
+
+    <parent>
+        <groupId>com.example.issue_314</groupId>
+        <artifactId>issue_314_parent</artifactId>
+        <version>1.0.0</version>
+    </parent>
+
+    <artifactId>dependency_B</artifactId>
+
+    <name>Dependency B</name>
+
+    <properties>
+        <maven.compiler.target>1.8</maven.compiler.target>
+        <maven.compiler.source>1.8</maven.compiler.source>
+    </properties>
+</project>
diff --git a/src/test/resources/issue-314/dependency_B/src/main/java/com/example/issue_314/libb/LibB.java b/src/test/resources/issue-314/dependency_B/src/main/java/com/example/issue_314/libb/LibB.java
new file mode 100644
index 0000000..c9741b4
--- /dev/null
+++ b/src/test/resources/issue-314/dependency_B/src/main/java/com/example/issue_314/libb/LibB.java
@@ -0,0 +1,9 @@
+package com.example.issue_314.libb;
+
+public class LibB {
+    private LibB() {}
+
+    public static void libBMethod() {
+        System.out.println("In libB");
+    }
+}
\ No newline at end of file
diff --git a/src/test/resources/issue-314/dependency_C/pom.xml b/src/test/resources/issue-314/dependency_C/pom.xml
new file mode 100644
index 0000000..0a45e02
--- /dev/null
+++ b/src/test/resources/issue-314/dependency_C/pom.xml
@@ -0,0 +1,31 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<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/maven-v4_0_0.xsd">
+
+    <modelVersion>4.0.0</modelVersion>
+
+    <parent>
+        <groupId>com.example.issue_314</groupId>
+        <artifactId>issue_314_parent</artifactId>
+        <version>1.0.0</version>
+    </parent>
+
+    <artifactId>dependency_C</artifactId>
+
+    <name>Dependency C</name>
+
+    <properties>
+        <maven.compiler.target>1.8</maven.compiler.target>
+        <maven.compiler.source>1.8</maven.compiler.source>
+    </properties>
+
+    <dependencies>
+        <dependency>
+            <groupId>com.example.issue_314</groupId>
+            <artifactId>dependency_D</artifactId>
+            <version>1.0.0</version>
+            <scope>compile</scope>
+        </dependency>
+    </dependencies>
+</project>
diff --git a/src/test/resources/issue-314/dependency_C/src/main/java/com/example/issue_314/libc/LibC.java b/src/test/resources/issue-314/dependency_C/src/main/java/com/example/issue_314/libc/LibC.java
new file mode 100644
index 0000000..bfeb36c
--- /dev/null
+++ b/src/test/resources/issue-314/dependency_C/src/main/java/com/example/issue_314/libc/LibC.java
@@ -0,0 +1,12 @@
+package com.example.issue_314.libc;
+
+import com.example.issue_314.libd.LibD;
+
+public class LibC {
+    private LibC() {}
+
+    public static void libCMethod() {
+        System.out.println("In libC");
+        LibD.libDMethod();
+    }
+}
\ No newline at end of file
diff --git a/src/test/resources/issue-314/dependency_D/pom.xml b/src/test/resources/issue-314/dependency_D/pom.xml
new file mode 100644
index 0000000..73d747a
--- /dev/null
+++ b/src/test/resources/issue-314/dependency_D/pom.xml
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<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/maven-v4_0_0.xsd">
+
+    <modelVersion>4.0.0</modelVersion>
+
+    <parent>
+        <groupId>com.example.issue_314</groupId>
+        <artifactId>issue_314_parent</artifactId>
+        <version>1.0.0</version>
+    </parent>
+
+    <artifactId>dependency_D</artifactId>
+
+    <name>Dependency D</name>
+
+    <properties>
+        <maven.compiler.target>1.8</maven.compiler.target>
+        <maven.compiler.source>1.8</maven.compiler.source>
+    </properties>
+</project>
diff --git a/src/test/resources/issue-314/dependency_D/src/main/java/com/example/issue_314/libd/LibD.java b/src/test/resources/issue-314/dependency_D/src/main/java/com/example/issue_314/libd/LibD.java
new file mode 100644
index 0000000..6cd6977
--- /dev/null
+++ b/src/test/resources/issue-314/dependency_D/src/main/java/com/example/issue_314/libd/LibD.java
@@ -0,0 +1,9 @@
+package com.example.issue_314.libd;
+
+public class LibD {
+    private LibD() {}
+
+    public static void libDMethod() {
+        System.out.println("In libD");
+    }
+}
\ No newline at end of file
diff --git a/src/test/resources/issue-314/pom.xml b/src/test/resources/issue-314/pom.xml
new file mode 100644
index 0000000..64d8cb9
--- /dev/null
+++ b/src/test/resources/issue-314/pom.xml
@@ -0,0 +1,26 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<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/maven-v4_0_0.xsd">
+
+    <modelVersion>4.0.0</modelVersion>
+
+    <groupId>com.example.issue_314</groupId>
+    <artifactId>issue_314_parent</artifactId>
+    <packaging>pom</packaging>
+    <version>1.0.0</version>
+
+    <name>Optionality Tests Parent</name>
+
+    <modules>
+        <module>dependency_A</module>
+        <module>dependency_B</module>
+        <module>dependency_C</module>
+        <module>dependency_D</module>
+    </modules>
+
+    <properties>
+        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+    </properties>
+</project>
+