You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maven.apache.org by sj...@apache.org on 2026/08/25 21:21:42 UTC

(maven-compiler-plugin) branch maven-compiler-plugin-3.x updated: [MCOMPILER-578] Track outputs across compiler executions (#1091)

This is an automated email from the ASF dual-hosted git repository.

slawekjaranowski pushed a commit to branch maven-compiler-plugin-3.x
in repository https://gitbox.apache.org/repos/asf/maven-compiler-plugin.git


The following commit(s) were added to refs/heads/maven-compiler-plugin-3.x by this push:
     new a0b689e  [MCOMPILER-578] Track outputs across compiler executions (#1091)
a0b689e is described below

commit a0b689e0e7f13d3a7dded7847a807fe6453e0358
Author: Václav Haisman <vh...@gmail.com>
AuthorDate: Tue Aug 25 23:21:36 2026 +0200

    [MCOMPILER-578] Track outputs across compiler executions (#1091)
    
    * [MCOMPILER-578] Add regression test for repeated compiler executions
    
    Run clean compile followed by compile and verify that Java 8 classes and the Java 11 module descriptor retain their configured class-file versions. The test exposes the incremental compilation failure before the production fix.
    
    * [MCOMPILER-578] Track outputs across compiler executions
    
    Record the last compiler execution that processed each mapped output in the shared plugin context. Recompile when a different execution overlaps that output while allowing repeated invocations of the same execution to remain up to date.
    
    Fixes #788
---
 src/it/MCOMPILER-578/invoker.properties            |  20 +++
 src/it/MCOMPILER-578/pom.xml                       |  78 +++++++++++
 .../MCOMPILER-578/src/main/java/module-info.java   |  21 +++
 .../src/main/java/org/example/Example.java         |  21 +++
 .../src/main/java/org/example/package-info.java    |  19 +++
 src/it/MCOMPILER-578/verify.groovy                 |  35 +++++
 .../plugin/compiler/AbstractCompilerMojo.java      |  69 +++++++++-
 .../plugin/compiler/CompilationOutputRegistry.java | 150 +++++++++++++++++++++
 .../compiler/CompilationOutputRegistryTest.java    | 105 +++++++++++++++
 9 files changed, 517 insertions(+), 1 deletion(-)

diff --git a/src/it/MCOMPILER-578/invoker.properties b/src/it/MCOMPILER-578/invoker.properties
new file mode 100644
index 0000000..b5bbe7d
--- /dev/null
+++ b/src/it/MCOMPILER-578/invoker.properties
@@ -0,0 +1,20 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+invoker.java.version = 17+
+invoker.goals = clean compile
+invoker.goals.2 = compile
diff --git a/src/it/MCOMPILER-578/pom.xml b/src/it/MCOMPILER-578/pom.xml
new file mode 100644
index 0000000..b3db4fc
--- /dev/null
+++ b/src/it/MCOMPILER-578/pom.xml
@@ -0,0 +1,78 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements.  See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership.  The ASF licenses this file
+to you under the Apache License, Version 2.0 (the
+"License"); you may not use this file except in compliance
+with the License.  You may obtain a copy of the License at
+
+  http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an
+"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+KIND, either express or implied.  See the License for the
+specific language governing permissions and limitations
+under the License.
+-->
+
+<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>org.apache.maven.plugins.compiler.it</groupId>
+  <artifactId>mcompiler-578</artifactId>
+  <version>1.0-SNAPSHOT</version>
+
+  <properties>
+    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+  </properties>
+
+  <build>
+    <plugins>
+      <plugin>
+        <groupId>org.apache.maven.plugins</groupId>
+        <artifactId>maven-compiler-plugin</artifactId>
+        <version>@project.version@</version>
+        <configuration>
+          <createMissingPackageInfoClass>false</createMissingPackageInfoClass>
+        </configuration>
+        <executions>
+          <execution>
+            <id>default-compile</id>
+            <configuration>
+              <release>${java.vm.specification.version}</release>
+            </configuration>
+          </execution>
+          <execution>
+            <id>base-modules-compile</id>
+            <goals>
+              <goal>compile</goal>
+            </goals>
+            <configuration>
+              <release>11</release>
+              <includes>
+                <include>module-info.java</include>
+              </includes>
+            </configuration>
+          </execution>
+          <execution>
+            <id>base-compile</id>
+            <goals>
+              <goal>compile</goal>
+            </goals>
+            <configuration>
+              <release>8</release>
+              <excludes>
+                <exclude>module-info.java</exclude>
+              </excludes>
+            </configuration>
+          </execution>
+        </executions>
+      </plugin>
+    </plugins>
+  </build>
+</project>
diff --git a/src/it/MCOMPILER-578/src/main/java/module-info.java b/src/it/MCOMPILER-578/src/main/java/module-info.java
new file mode 100644
index 0000000..1e1d899
--- /dev/null
+++ b/src/it/MCOMPILER-578/src/main/java/module-info.java
@@ -0,0 +1,21 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+module org.example {
+    exports org.example;
+}
diff --git a/src/it/MCOMPILER-578/src/main/java/org/example/Example.java b/src/it/MCOMPILER-578/src/main/java/org/example/Example.java
new file mode 100644
index 0000000..c520a4f
--- /dev/null
+++ b/src/it/MCOMPILER-578/src/main/java/org/example/Example.java
@@ -0,0 +1,21 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.example;
+
+public class Example {}
diff --git a/src/it/MCOMPILER-578/src/main/java/org/example/package-info.java b/src/it/MCOMPILER-578/src/main/java/org/example/package-info.java
new file mode 100644
index 0000000..9517db2
--- /dev/null
+++ b/src/it/MCOMPILER-578/src/main/java/org/example/package-info.java
@@ -0,0 +1,19 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.example;
diff --git a/src/it/MCOMPILER-578/verify.groovy b/src/it/MCOMPILER-578/verify.groovy
new file mode 100644
index 0000000..279dd9c
--- /dev/null
+++ b/src/it/MCOMPILER-578/verify.groovy
@@ -0,0 +1,35 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+def majorVersion = { File classFile ->
+    assert classFile.isFile()
+    byte[] bytes = classFile.bytes
+    assert bytes.length >= 8
+    ((bytes[6] & 0xFF) << 8) | (bytes[7] & 0xFF)
+}
+
+def exampleClass = new File( basedir, 'target/classes/org/example/Example.class' )
+def exampleMajorVersion = majorVersion( exampleClass )
+// major_version: 52 = Java 8, from the base-compile execution.
+assert exampleMajorVersion == 52
+
+def moduleInfoClass = new File( basedir, 'target/classes/module-info.class' )
+def moduleInfoMajorVersion = majorVersion( moduleInfoClass )
+// major_version: 55 = Java 11, from the base-modules-compile execution.
+assert moduleInfoMajorVersion == 55
diff --git a/src/main/java/org/apache/maven/plugin/compiler/AbstractCompilerMojo.java b/src/main/java/org/apache/maven/plugin/compiler/AbstractCompilerMojo.java
index 2066f0e..709dd89 100644
--- a/src/main/java/org/apache/maven/plugin/compiler/AbstractCompilerMojo.java
+++ b/src/main/java/org/apache/maven/plugin/compiler/AbstractCompilerMojo.java
@@ -956,6 +956,9 @@ public abstract class AbstractCompilerMojo extends AbstractMojo {
 
         final Set<File> sources;
 
+        Set<Path> outputs = Collections.emptySet();
+        String compilerExecution = mojoExecution.getGoal() + '@' + mojoExecution.getExecutionId();
+
         IncrementalBuildHelperRequest incrementalBuildHelperRequest = null;
 
         if (useIncrementalCompilation) {
@@ -967,6 +970,9 @@ public abstract class AbstractCompilerMojo extends AbstractMojo {
 
                 preparePaths(sources);
 
+                // Expected output paths let us detect overwrites that IncrementalBuildHelper cannot see.
+                outputs = getOutputPaths(compilerConfiguration, compiler, sources);
+
                 incrementalBuildHelperRequest = new IncrementalBuildHelperRequest().inputFiles(sources);
 
                 // Strategies used to detect modifications.
@@ -980,9 +986,18 @@ public abstract class AbstractCompilerMojo extends AbstractMojo {
                 String inputFileTreeChanged = hasInputFileTreeChanged(incrementalBuildHelper, sources)
                         ? "added or removed source files"
                         : null;
+                // A different execution may have overwritten an otherwise up-to-date output.
+                String outputChanged = hasPreviouslyCompiledOutput(compilerExecution, outputs)
+                        ? "output from another compiler execution"
+                        : null;
 
                 // Get the first cause for the rebuild compilation detection.
-                String cause = Stream.of(immutableOutputFile, dependencyChanged, sourceChanged, inputFileTreeChanged)
+                String cause = Stream.of(
+                                immutableOutputFile,
+                                dependencyChanged,
+                                sourceChanged,
+                                inputFileTreeChanged,
+                                outputChanged)
                         .filter(Objects::nonNull)
                         .findFirst()
                         .orElse(null);
@@ -1293,6 +1308,8 @@ public abstract class AbstractCompilerMojo extends AbstractMojo {
                 getLog().debug(
                                 "skip incrementalBuildHelper#afterRebuildExecution as the output directory doesn't exist");
             }
+            // Make these outputs visible to later compiler executions in this Maven session.
+            recordCompiledOutputs(compilerExecution, outputs);
         }
 
         List<CompilerMessage> warnings = new ArrayList<>();
@@ -1617,6 +1634,56 @@ public abstract class AbstractCompilerMojo extends AbstractMojo {
         return staleSources;
     }
 
+    /**
+     * Maps selected sources to their expected compiler outputs.
+     *
+     * @param compilerConfiguration the compiler configuration
+     * @param compiler the selected compiler
+     * @param sources sources selected by the current execution
+     * @return normalized absolute output paths
+     */
+    private Set<Path> getOutputPaths(CompilerConfiguration compilerConfiguration, Compiler compiler, Set<File> sources)
+            throws CompilerException, MojoExecutionException {
+        SourceMapping mapping = getSourceMapping(compilerConfiguration, compiler);
+
+        File outputDirectory =
+                compiler.getCompilerOutputStyle() == CompilerOutputStyle.ONE_OUTPUT_FILE_FOR_ALL_INPUT_FILES
+                        ? buildDirectory
+                        : getOutputDirectory();
+        try {
+            return CompilationOutputRegistry.mapOutputs(mapping, outputDirectory, getCompileSourceRoots(), sources);
+        } catch (InclusionScanException e) {
+            throw new MojoExecutionException("Error mapping sources to their outputs.", e);
+        }
+    }
+
+    /**
+     * Checks whether a different compiler execution last processed an expected output.
+     *
+     * @param compilerExecution the current execution
+     * @param outputs expected outputs of the current execution
+     * @return whether an output overlaps with another execution
+     */
+    private boolean hasPreviouslyCompiledOutput(String compilerExecution, Set<Path> outputs) {
+        Optional<Path> output = CompilationOutputRegistry.find(getPluginContext(), compilerExecution, outputs);
+        if (output.isPresent() && showCompilationChanges) {
+            getLog().info("\tOutput from another compiler execution: " + output.get());
+        } else if (output.isPresent()) {
+            getLog().debug("\tOutput from another compiler execution: " + output.get());
+        }
+        return output.isPresent();
+    }
+
+    /**
+     * Registers the current execution as the last processor of its expected outputs.
+     *
+     * @param compilerExecution the current execution
+     * @param outputs expected outputs of the current execution
+     */
+    private void recordCompiledOutputs(String compilerExecution, Set<Path> outputs) {
+        CompilationOutputRegistry.register(getPluginContext(), compilerExecution, outputs);
+    }
+
     private SourceMapping getSourceMapping(CompilerConfiguration compilerConfiguration, Compiler compiler)
             throws CompilerException, MojoExecutionException {
         CompilerOutputStyle outputStyle = compiler.getCompilerOutputStyle();
diff --git a/src/main/java/org/apache/maven/plugin/compiler/CompilationOutputRegistry.java b/src/main/java/org/apache/maven/plugin/compiler/CompilationOutputRegistry.java
new file mode 100644
index 0000000..110ad9f
--- /dev/null
+++ b/src/main/java/org/apache/maven/plugin/compiler/CompilationOutputRegistry.java
@@ -0,0 +1,150 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.plugin.compiler;
+
+import java.io.File;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+
+import org.codehaus.plexus.compiler.util.scan.InclusionScanException;
+import org.codehaus.plexus.compiler.util.scan.mapping.SourceMapping;
+
+/**
+ * Tracks output files processed by compiler executions for the current project and Maven session.
+ *
+ * <p>Multiple compiler executions may share an output directory while using different compiler options.
+ * {@link org.apache.maven.shared.incremental.IncrementalBuildHelper} compares output file names before and after
+ * compilation, so it cannot detect an existing output overwritten by another execution. This registry lets a later
+ * execution detect that overlap without relying on file timestamps, whose precision varies between file systems.</p>
+ *
+ * <p>The registry is stored in Maven's plugin context, which is shared by all executions of this plugin for one
+ * project and discarded after the Maven session.</p>
+ */
+final class CompilationOutputRegistry {
+    private static final String KEY = CompilationOutputRegistry.class.getName() + ".compiledOutputs";
+
+    /**
+     * Prevents instantiation.
+     */
+    private CompilationOutputRegistry() {}
+
+    /**
+     * Maps sources to normalized absolute output paths.
+     *
+     * @param mapping mapping from source paths to output paths
+     * @param outputDirectory compiler output directory
+     * @param sourceRoots configured source roots
+     * @param sources sources selected by the compiler execution
+     * @return expected output paths
+     * @throws InclusionScanException if a source cannot be mapped
+     */
+    static Set<Path> mapOutputs(
+            SourceMapping mapping, File outputDirectory, List<String> sourceRoots, Set<File> sources)
+            throws InclusionScanException {
+        Set<Path> outputs = new HashSet<>();
+        List<Path> roots = new ArrayList<>(sourceRoots.size());
+        for (String sourceRoot : sourceRoots) {
+            roots.add(Paths.get(sourceRoot).toAbsolutePath().normalize());
+        }
+        for (File source : sources) {
+            Path path = source.toPath().toAbsolutePath().normalize();
+            Path matchingRoot = null;
+            for (Path root : roots) {
+                if (path.startsWith(root)
+                        && (matchingRoot == null || root.getNameCount() > matchingRoot.getNameCount())) {
+                    matchingRoot = root;
+                }
+            }
+            if (matchingRoot != null) {
+                for (File output : mapping.getTargetFiles(
+                        outputDirectory, matchingRoot.relativize(path).toString())) {
+                    outputs.add(output.toPath().toAbsolutePath().normalize());
+                }
+            }
+        }
+        return outputs;
+    }
+
+    /**
+     * Finds an output most recently processed by a different compiler execution.
+     *
+     * @param pluginContext Maven's context for this plugin and project
+     * @param compilerExecution the current compiler execution, formatted as {@code goal@executionId}
+     * @param outputs outputs mapped from the current execution's sources
+     * @return an overlapping output, or an empty value if there is none
+     */
+    static Optional<Path> find(Map<?, ?> pluginContext, String compilerExecution, Set<Path> outputs) {
+        Map<Path, String> compiledOutputs = get(pluginContext, false);
+        return compiledOutputs != null
+                ? outputs.stream()
+                        .filter(output -> {
+                            String previousExecution = compiledOutputs.get(output);
+                            return previousExecution != null && !previousExecution.equals(compilerExecution);
+                        })
+                        .findFirst()
+                : Optional.empty();
+    }
+
+    /**
+     * Associates outputs with the compiler execution that most recently processed them.
+     *
+     * @param pluginContext Maven's context for this plugin and project
+     * @param compilerExecution the compiler execution, formatted as {@code goal@executionId}
+     * @param outputs outputs mapped from the execution's sources
+     */
+    static void register(Map<?, ?> pluginContext, String compilerExecution, Set<Path> outputs) {
+        Map<Path, String> compiledOutputs = get(pluginContext, true);
+        if (compiledOutputs != null) {
+            for (Path output : outputs) {
+                compiledOutputs.put(output, compilerExecution);
+            }
+        }
+    }
+
+    /**
+     * Returns the output registry from the plugin context.
+     *
+     * @param pluginContext Maven's context for this plugin and project
+     * @param create whether to create the registry when absent
+     * @return the registry, or {@code null} if the context or registry is absent
+     */
+    @SuppressWarnings("unchecked")
+    private static Map<Path, String> get(Map<?, ?> pluginContext, boolean create) {
+        if (pluginContext == null) {
+            return null;
+        }
+        Object value = pluginContext.get(KEY);
+        if (value == null && create) {
+            Map<Path, String> outputs = new ConcurrentHashMap<>();
+            Map<Object, Object> context = (Map<Object, Object>) pluginContext;
+            value = context.putIfAbsent(KEY, outputs);
+            if (value == null) {
+                return outputs;
+            }
+        }
+        return (Map<Path, String>) value;
+    }
+}
diff --git a/src/test/java/org/apache/maven/plugin/compiler/CompilationOutputRegistryTest.java b/src/test/java/org/apache/maven/plugin/compiler/CompilationOutputRegistryTest.java
new file mode 100644
index 0000000..a76ab2d
--- /dev/null
+++ b/src/test/java/org/apache/maven/plugin/compiler/CompilationOutputRegistryTest.java
@@ -0,0 +1,105 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.plugin.compiler;
+
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+
+import org.codehaus.plexus.compiler.util.scan.InclusionScanException;
+import org.codehaus.plexus.compiler.util.scan.mapping.SuffixMapping;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+
+class CompilationOutputRegistryTest {
+    private static final String FIRST_EXECUTION = "compile@first";
+
+    private static final String SECOND_EXECUTION = "compile@second";
+
+    private static final Path OUTPUT =
+            Paths.get("target/classes/Example.class").toAbsolutePath().normalize();
+
+    private static final Path OTHER_OUTPUT =
+            Paths.get("target/classes/Other.class").toAbsolutePath().normalize();
+
+    private final Map<String, Object> pluginContext = new HashMap<>();
+
+    private final Set<Path> outputs = Collections.singleton(OUTPUT);
+
+    @Test
+    void shouldMapSourceAgainstMostSpecificRoot() throws InclusionScanException {
+        Path sourceRoot = Paths.get("src").toAbsolutePath().normalize();
+        Path nestedSourceRoot = sourceRoot.resolve("main/java");
+        Path outputDirectory = Paths.get("target/classes").toAbsolutePath().normalize();
+        Path source = nestedSourceRoot.resolve("org/example/Example.java");
+
+        assertEquals(
+                Collections.singleton(outputDirectory.resolve("org/example/Example.class")),
+                CompilationOutputRegistry.mapOutputs(
+                        new SuffixMapping(".java", ".class"),
+                        outputDirectory.toFile(),
+                        Arrays.asList(sourceRoot.toString(), nestedSourceRoot.toString()),
+                        Collections.singleton(source.toFile())));
+    }
+
+    @Test
+    void shouldIgnoreOutputsFromTheSameExecution() {
+        CompilationOutputRegistry.register(pluginContext, FIRST_EXECUTION, outputs);
+
+        assertFalse(CompilationOutputRegistry.find(pluginContext, FIRST_EXECUTION, outputs)
+                .isPresent());
+    }
+
+    @Test
+    void shouldFindOutputsFromAnotherExecution() {
+        CompilationOutputRegistry.register(pluginContext, FIRST_EXECUTION, outputs);
+
+        assertEquals(
+                OUTPUT,
+                CompilationOutputRegistry.find(pluginContext, SECOND_EXECUTION, outputs)
+                        .orElse(null));
+    }
+
+    @Test
+    void shouldIgnoreDisjointOutputs() {
+        CompilationOutputRegistry.register(pluginContext, FIRST_EXECUTION, outputs);
+
+        assertFalse(CompilationOutputRegistry.find(pluginContext, SECOND_EXECUTION, Collections.singleton(OTHER_OUTPUT))
+                .isPresent());
+    }
+
+    @Test
+    void shouldTrackTheLastExecution() {
+        CompilationOutputRegistry.register(pluginContext, FIRST_EXECUTION, outputs);
+        CompilationOutputRegistry.register(pluginContext, SECOND_EXECUTION, outputs);
+
+        assertEquals(
+                OUTPUT,
+                CompilationOutputRegistry.find(pluginContext, FIRST_EXECUTION, outputs)
+                        .orElse(null));
+        assertFalse(CompilationOutputRegistry.find(pluginContext, SECOND_EXECUTION, outputs)
+                .isPresent());
+    }
+}