gnodet-bot commented on code in PR #1157:
URL:
https://github.com/apache/maven-compiler-plugin/pull/1157#discussion_r4192279179
##########
src/main/java/org/apache/maven/plugin/compiler/ToolExecutor.java:
##########
@@ -915,6 +919,241 @@ private static boolean removeFirsts(Deque<Path> paths,
Integer count) {
}
}
+ /**
+ * Compiles using the dependency-graph incremental strategy. This method
handles the full
+ * lifecycle: determining what to compile, running javac, cascading on
changed classes,
+ * and persisting state.
+ *
+ * @param compiler the compiler
+ * @param configuration the options to give to the Java compiler
+ * @param mojo the MOJO for configuration access
+ * @throws IOException if an error occurred while reading or writing a file
+ * @throws MojoException if the compilation failed
+ */
+ void compileWithGraphIncremental(JavaCompiler compiler, Options
configuration, AbstractCompilerMojo mojo)
+ throws IOException {
+ var graphBuild = new GraphIncrementalBuild(outputDirectory);
+
+ // Collect annotation processor path for processor classification
+ var processorPaths = new ArrayList<Path>();
+ for (var entry : dependencies.entrySet()) {
+ if (entry.getKey() instanceof JavaPathType type) {
+ var location = type.location();
+ if (location.isPresent()
+ && (location.get() ==
StandardLocation.ANNOTATION_PROCESSOR_PATH
+ || location.get() ==
StandardLocation.ANNOTATION_PROCESSOR_MODULE_PATH)) {
+ processorPaths.addAll(entry.getValue());
+ }
+ }
+ }
+ if (!processorPaths.isEmpty()) {
+ graphBuild.setProcessorPath(processorPaths);
+ }
+
+ // Collect classpath entries for external dependency tracking
+ var classpathEntries = new ArrayList<Path>();
+ var reactorModulePaths = new LinkedHashSet<Path>();
+ for (var entry : dependencies.entrySet()) {
+ if (entry.getKey() instanceof JavaPathType type) {
+ var location = type.location();
+ if (location.isPresent() && location.get() ==
StandardLocation.CLASS_PATH) {
+ for (Path p : entry.getValue()) {
+ classpathEntries.add(p);
+ if (Files.isDirectory(p)) {
+ reactorModulePaths.add(p);
+ }
+ }
+ }
+ }
+ }
+ if (!classpathEntries.isEmpty()) {
+ graphBuild.setClasspathEntries(classpathEntries);
+ }
+ if (!reactorModulePaths.isEmpty()) {
+ graphBuild.setReactorModulePaths(reactorModulePaths);
+ }
+
+ // Hash module-info-patch.maven files for config change detection
+ graphBuild.setConfigHash(computeConfigHash(configuration));
+
+ // Collect all source file paths
+ var allSourcePaths = new ArrayList<Path>();
+ for (SourceFile sf : sourceFiles) {
+ allSourcePaths.add(sf.file);
+ }
+
+ Set<Path> toCompile = graphBuild.initialize(allSourcePaths);
+ if (toCompile.isEmpty()) {
+ logger.info("Nothing to compile - all classes are up to date
(graph strategy).");
+ graphBuild.finish();
+ return;
+ }
+
+ logger.info(
+ graphBuild.isFullBuild()
+ ? "Compiling " + toCompile.size() + " source file(s)
(graph: full build)."
+ : "Compiling " + toCompile.size() + " source file(s)
(graph: incremental).");
+ if (mojo.showCompilationChanges && graphBuild.getRebuildCause() !=
null) {
+ logger.info("Rebuild cause: " + graphBuild.getRebuildCause());
+ for (Path f : toCompile) {
+ logger.info(" " + f);
+ }
+ }
+
+ var originalSourceFiles = new ArrayList<>(sourceFiles);
+ boolean success = true;
+ // Safety bound: the compile set is monotonically growing (bounded by
total source count).
+ // If a bug causes processCompiledClasses to return files already
compiled, this prevents
+ // an infinite loop. In practice this limit should never be reached.
+ int maxRounds = originalSourceFiles.size() + 1;
+ int rounds = 0;
+
+ try {
+ while (!toCompile.isEmpty()) {
+ if (++rounds > maxRounds) {
+ throw new IllegalStateException("graph cascade loop did
not converge after " + maxRounds
+ + " rounds — " + "possible dependency cycle or bug
in processCompiledClasses()");
+ }
+ Set<Path> compileSet = toCompile;
+ sourceFiles = originalSourceFiles.stream()
+ .filter(sf -> compileSet.contains(sf.file))
+ .collect(Collectors.toList());
+
+ if (sourceFiles.isEmpty()) {
+ break;
+ }
+
+ var compilerOutput = new StringWriter();
+ success = compileIncrementalRound(compiler, configuration,
compilerOutput, graphBuild);
+ String output = compilerOutput.toString();
+ if (!output.isBlank()) {
+ logger.warn(output);
+ }
+ if (!success) {
+ break;
+ }
+
+ toCompile = graphBuild.processCompiledClasses(compileSet);
+ if (!toCompile.isEmpty()) {
+ logger.info("graph cascade: recompiling " +
toCompile.size() + " additional file(s).");
+ if (mojo.showCompilationChanges) {
+ for (Path f : toCompile) {
+ logger.info(" " + f);
+ }
+ }
+ }
+ }
+ } finally {
+ sourceFiles = originalSourceFiles;
+ }
+
+ if (success) {
+ graphBuild.finish();
+ logger.info("Compiled " + graphBuild.compiledCount() + " file(s),
" + graphBuild.unchangedCount()
+ + " unchanged (graph strategy).");
+ } else {
+ graphBuild.invalidate();
+ throw new CompilationFailureException("Compilation failed (graph
incremental strategy).");
+ }
+ }
+
+ private String computeConfigHash(Options configuration) {
+ var digest = new StringBuilder();
+
+ // Include compiler options in the config hash so changes to
-source/-target/-release
+ // etc. trigger a full rebuild under the graph strategy.
+ String optionsRepr = String.join("|", configuration.options);
+ digest.append("opts:").append(optionsRepr).append(';');
+
+ for (SourceDirectory source : sourceDirectories) {
+ Path patchFile = source.root.resolve(ModuleInfoPatch.FILENAME);
+ if (Files.isRegularFile(patchFile)) {
+ try {
+ byte[] content = Files.readAllBytes(patchFile);
+ digest.append(patchFile)
Review Comment:
⚠️ **Medium — `compileIncrementalRound` sets `SOURCE_PATH` to empty list**
```java
fileManager.setLocationFromPaths(StandardLocation.SOURCE_PATH, List.of());
```
Setting an empty source path is intentional (to prevent javac from
auto-discovering sources), but it interacts poorly with JPMS modules. When
compiling a subset of sources in a modular project, javac needs to resolve
`module-info.java` for the module being compiled. If `module-info.java` is not
in the current compile set (because it didn't change), javac won't find it via
the source path.
The `class-path` includes `outputDirectory` which has the compiled
`module-info.class`, so this may work in practice, but it's fragile — verify
that the test `moduleInfoChangeIsDetectedAsIncremental` actually exercises this
path with a partial compile set that excludes `module-info.java`.
##########
src/main/java/org/apache/maven/plugin/compiler/incremental/GraphIncrementalBuild.java:
##########
@@ -0,0 +1,910 @@
+/*
+ * 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.incremental;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeSet;
+import java.util.stream.Collectors;
+
+/**
+ * Dependency-graph-driven incremental build engine, designed for embedding
+ * in maven-compiler-plugin alongside the existing timestamp-based
+ * {@code IncrementalBuild}.
+ *
+ * <p>The plugin drives compilation; this class determines <em>what</em> to
+ * compile and performs post-compilation bytecode analysis to build the
+ * class-level dependency graph. Typical usage:
+ *
+ * {@snippet :
+ * var build = new GraphIncrementalBuild(outputDir);
+ * build.setProcessorPath(processorPath);
+ * build.setConfigHash(configHash);
+ *
+ * Set<Path> toCompile = build.initialize(allSourceFiles);
+ *
+ * while (!toCompile.isEmpty()) {
+ * compiler.compile(toCompile); // any compiler, any mode
+ * toCompile = build.processCompiledClasses(toCompile);
+ * }
+ *
+ * build.finish();
+ * }
+ *
+ * <p>After each compilation pass, {@link #processCompiledClasses(Set)} scans
+ * the freshly produced {@code .class} files, updates the dependency graph,
+ * and returns any additional source files that must be compiled in the next
+ * pass (cascade due to changed classes, or newly discovered dependencies).
+ * The loop converges in at most 2–3 passes in practice.
+ *
+ * <p>The engine persists its state as {@code incremental-state} in the
+ * {@code target/maven-status/maven-compiler-plugin/<outputDirName>/}
directory (outside the class
+ * output directory so it is not packaged into JARs).
+ *
+ * @see IncrementalState
+ */
+public class GraphIncrementalBuild {
+
+ /** Prefix used to distinguish module-info entries from regular type
entries in the state. */
+ static final String MODULE_PREFIX = BytecodeAnalyzer.MODULE_PREFIX;
+
+ private final Path outputDir;
+ private final Path buildDir;
+ private final Path stateFile;
+ private List<Path> classpathEntries;
+ private Set<Path> reactorModulePaths;
+ private List<Path> processorPath;
+ private ProcessorClassification processorClassification;
+
+ private IncrementalState previousState;
+ private IncrementalState state;
+
+ /** Package-private accessor for tests. */
+ IncrementalState getState() {
+ return state;
+ }
+
+ private Map<String, String> sourceHashes;
+ private Map<String, Long> sourceMtimes;
+ private List<Path> allSourceFiles;
+ private Set<String> allCompiled;
+ private boolean fullBuild;
+ private boolean useModulePrefixedPaths;
+ private String configHash = "";
+ private String rebuildCause;
+ private int totalSources;
+ /** Lazily populated on full builds; maps each output class file to its
simple top-level class name. */
+ private Map<Path, String> outputClassIndex;
+
+ public GraphIncrementalBuild(Path outputDir) {
+ this.outputDir = outputDir.toAbsolutePath();
+ this.buildDir = this.outputDir.getParent() != null ?
this.outputDir.getParent() : this.outputDir;
+ // Store state outside the output directory so it is not included in
the JAR.
+ // Use the same maven-status convention as the timestamp-based
strategy.
+ // Include the output directory name (e.g. "classes", "test-classes")
to avoid
+ // collisions between compile and testCompile executions.
+ String outputDirName = outputDir.getFileName().toString();
+ Path mavenStatus =
buildDir.resolve("maven-status").resolve("maven-compiler-plugin");
+ this.stateFile =
mavenStatus.resolve(outputDirName).resolve("incremental-state");
+ }
+
+ /**
+ * Sets classpath entries for external dependency tracking.
+ */
+ public void setClasspathEntries(List<Path> entries) {
+ this.classpathEntries = entries;
+ }
+
+ /**
+ * Marks specific classpath entries as reactor modules.
+ */
+ public void setReactorModulePaths(Set<Path> paths) {
+ this.reactorModulePaths = paths;
+ }
+
+ /**
+ * Sets the annotation processor classpath for processor classification.
+ * Entries are scanned for {@code
META-INF/maven/compiler/incremental.annotation.processors}
+ * and {@code META-INF/gradle/incremental.annotation.processors} to
determine
+ * whether each processor is {@link ProcessorType#ISOLATING},
+ * {@link ProcessorType#AGGREGATING}, or {@link ProcessorType#UNKNOWN}.
+ */
+ public void setProcessorPath(List<Path> processorPath) {
+ this.processorPath = processorPath;
+ this.processorClassification = new
ProcessorClassification(processorPath);
+ }
+
+ /**
+ * Indicates that class files are written under module-name subdirectories
+ * of the output directory (MODULE_SOURCE hierarchy). When set, stored
module
+ * names are used as path prefixes when deleting class files.
+ */
+ public void setUseModulePrefixedPaths(boolean useModulePrefixedPaths) {
+ this.useModulePrefixedPaths = useModulePrefixedPaths;
+ }
+
+ /**
+ * Sets a hash of compilation context configuration (e.g. compiler options
+ * and module-info-patch files). If this hash differs from the previous
+ * build, a full rebuild is triggered.
+ */
+ public void setConfigHash(String hash) {
+ this.configHash = hash != null ? hash : "";
+ }
+
+ /**
+ * Initializes the incremental build by scanning source files and comparing
+ * against the previous build's state.
+ *
+ * @param allSourceFiles all source files in this module
+ * @return the set of files that need compilation (may be all files for a
+ * full build, a subset for incremental, or empty if up-to-date)
+ */
+ public Set<Path> initialize(List<Path> allSourceFiles) throws IOException {
+ Files.createDirectories(outputDir);
+
+ this.allSourceFiles = allSourceFiles;
+ totalSources = allSourceFiles.size();
+ allCompiled = new TreeSet<>();
+ previousState = IncrementalState.load(stateFile);
+ sourceMtimes = new LinkedHashMap<>();
+ sourceHashes = hashSourceFiles(allSourceFiles, previousState,
sourceMtimes);
+
+ if (previousState == null) {
+ rebuildCause = "no previous build state";
+ return initFullBuild(allSourceFiles);
+ } else if (!configHash.equals(previousState.getConfigHash())) {
+ rebuildCause = "compilation configuration changed
(module-info-patch.maven or compiler options)";
+ return initFullBuild(allSourceFiles);
+ } else {
+ return initIncrementalBuild(allSourceFiles);
+ }
+ }
+
+ /**
+ * Processes the {@code .class} files produced by the last compilation
pass.
+ * Scans each class file to extract the dependency graph, records changes,
+ * and returns any additional source files that must be compiled in the
next pass.
+ *
+ * <p>The cascade logic: any compiled class whose content changed (new or
modified)
+ * triggers recompilation of all source files that depend on it (signature
or
+ * implementation consumers).
+ *
+ * @param compiledSourceFiles the source files that were passed to the
compiler in this round
+ * @return additional source files to compile (may be empty when fixpoint
is reached)
+ * @throws IOException if reading {@code .class} files fails
+ */
+ public Set<Path> processCompiledClasses(Set<Path> compiledSourceFiles)
throws IOException {
+ // Reset the class index so it is rebuilt fresh for each compilation
round
+ outputClassIndex = null;
+
+ // Scan .class files for the types produced from the compiled source
files
+ var results = new LinkedHashMap<String, SourceFileAnalysis>();
+ for (Path sourceFile : compiledSourceFiles) {
+ collectClassAnalyses(sourceFile, results);
+ }
+
+ // All compiled types cascade to their consumers (any change triggers
recompilation)
+ var changedTypes = new TreeSet<>(results.keySet());
+
+ // Update incremental state with this round's results
+ for (var entry : sourceHashes.entrySet()) {
+ if (allCompiled.contains(entry.getKey())) {
+ state.setSourceHash(entry.getKey(), entry.getValue());
+ }
+ }
+ for (var result : results.values()) {
+ String moduleName = useModulePrefixedPaths ? result.moduleName() :
"";
+ IncrementalState.TypeInfo typeInfo;
+ typeInfo = new IncrementalState.GraphTypeInfo(
+ result.sourceFile(), result.classDeps(),
result.annotationTypes(), moduleName);
+ state.setType(result.qualifiedName(), typeInfo);
+ }
+
+ if (fullBuild || changedTypes.isEmpty()) {
+ return Set.of();
+ }
+
+ // Detect module name changes — require a full rebuild
+ if (previousState != null && hasModuleNameChanged(state,
previousState)) {
+ return forceFullRebuild();
+ }
+
+ // Cascade: find all consumers of changed types (both signature and
implementation)
+ var cascade = new TreeSet<>(changedTypes);
+ for (String type : changedTypes) {
+ expandSignatureCascade(type, state, cascade);
+ }
+
+ var additionalFiles = new TreeSet<Path>();
+ for (String cascadedType : cascade) {
+ for (String consumer : state.getAllConsumers(cascadedType)) {
+ String sf = state.sourceFileFor(consumer);
+ if (sf != null && !allCompiled.contains(sf)) {
+ additionalFiles.add(Path.of(sf));
+ allCompiled.add(sf);
+ }
+ }
+ }
+
+ // Annotation processor cascade
+ additionalFiles.addAll(computeProcessorCascade());
+
+ return additionalFiles;
+ }
+
+ /**
+ * Scans the output directory for {@code .class} files produced from the
given source file,
+ * analyzes each one with {@link BytecodeAnalyzer}, and accumulates {@link
SourceFileAnalysis}
+ * records into {@code results}.
+ *
+ * <p>The source→class mapping is reconstructed by looking up types
previously recorded for
+ * this source file in the previous state, and by scanning the output
directory for class
+ * files whose name prefix matches the source file's simple name. This
covers both primary
+ * classes and inner/anonymous classes ({@code Foo$Bar.class}).
+ */
+ private void collectClassAnalyses(Path sourceFile, Map<String,
SourceFileAnalysis> results) throws IOException {
+ String sourceFilePath = sourceFile.toString();
+ String simpleSourceName = sourceFile.getFileName().toString(); // e.g.
"Model.java"
+
+ // Map from class file path → pre-computed analysis (null if not yet
analyzed).
+ // Reuses the analysis from the package-dir walk to avoid double
BytecodeAnalyzer.analyze calls.
+ var classFileCache = new LinkedHashMap<Path,
BytecodeAnalyzer.ClassAnalysis>();
+
+ // Types previously tracked for this source file — their .class files
may have moved
+ if (previousState != null) {
+ for (String type :
previousState.getTypesFromSource(sourceFilePath)) {
+ Path classFile = classFileFor(type,
previousState.getType(type));
+ if (Files.exists(classFile)) {
+ classFileCache.put(classFile, null);
+ }
+ // Also scan for inner classes (Foo$Bar.class etc.)
+ var innerFiles = new TreeSet<Path>();
+ addInnerClassFiles(classFile, innerFiles);
+ for (Path inner : innerFiles) {
+ classFileCache.putIfAbsent(inner, null);
+ }
+ }
+ }
+
+ // Walk output directory entries for this source's simple name
+ // (handles new types introduced in this compilation)
+ Path packageDir = inferPackageDir(sourceFile);
+ if (packageDir != null && Files.isDirectory(packageDir)) {
+ try (var stream = Files.list(packageDir)) {
+ stream.filter(p -> p.toString().endsWith(".class")).forEach(cf
-> {
+ try {
+ // Match by SourceFile attribute — covers primary
class, inner/anonymous
+ // classes (Foo$Bar.class), AND package-private
secondary top-level classes
+ // (FooHelper in Foo.java), without cross-package
simple-name collisions.
+ var a = BytecodeAnalyzer.analyze(cf);
+ if (simpleSourceName.equals(a.sourceFileName())) {
+ classFileCache.put(cf, a); // cache the analysis
for reuse below
+ }
+ } catch (IOException e) {
+ // Log at debug level — a corrupted or inaccessible
class file is silently
+ // skipped; the missing type entry will trigger a full
rebuild next time.
+ System.getLogger(GraphIncrementalBuild.class.getName())
+ .log(System.Logger.Level.DEBUG, "Failed to
analyze class file: {0}", cf);
+ }
+ });
+ }
+ } else {
+ // No previous state for this source (full build or new file in
incremental build):
+ // use the cached class index keyed by SourceFile attribute value
(simple source name,
+ // e.g. "Foo.java"). This correctly associates package-private
secondary types and
+ // avoids cross-package simple-name collisions.
+ for (var entry : getFullBuildClassIndex().entrySet()) {
+ if (simpleSourceName.equals(entry.getValue())) {
+ classFileCache.putIfAbsent(entry.getKey(), null);
+ }
+ }
Review Comment:
⚠️ **Medium — cross-package simple-name collision in
`getFullBuildClassIndex` fallback**
When `inferPackageDir()` returns `null` (no previous state for this source
file), the code falls back to `getFullBuildClassIndex()` which matches class
files by the `SourceFile` attribute (simple name, e.g. `"Foo.java"`). If two
packages both contain `Foo.java` (e.g. `com/a/Foo.java` and `com/b/Foo.java`),
this will incorrectly add `com/b/Foo.class` into the class file cache when
processing `com/a/Foo.java`.
In the final pass, `com.b.Foo` would then be recorded with `sourceFilePath2
= com/a/Foo.java` (because `resolveSourceFile` falls back to the default when
there's no state entry yet). On full builds this is self-correcting because
both files are processed, but on incremental builds when only one of the two
same-named files is new, the dependency graph would be corrupted.
Consider cross-checking the class file's package directory against the
source file's expected package, or using the fully qualified class name to
validate the association.
##########
src/main/java/org/apache/maven/plugin/compiler/incremental/GraphIncrementalBuild.java:
##########
@@ -0,0 +1,910 @@
+/*
+ * 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.incremental;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeSet;
+import java.util.stream.Collectors;
+
+/**
+ * Dependency-graph-driven incremental build engine, designed for embedding
+ * in maven-compiler-plugin alongside the existing timestamp-based
+ * {@code IncrementalBuild}.
+ *
+ * <p>The plugin drives compilation; this class determines <em>what</em> to
+ * compile and performs post-compilation bytecode analysis to build the
+ * class-level dependency graph. Typical usage:
+ *
+ * {@snippet :
+ * var build = new GraphIncrementalBuild(outputDir);
+ * build.setProcessorPath(processorPath);
+ * build.setConfigHash(configHash);
+ *
+ * Set<Path> toCompile = build.initialize(allSourceFiles);
+ *
+ * while (!toCompile.isEmpty()) {
+ * compiler.compile(toCompile); // any compiler, any mode
+ * toCompile = build.processCompiledClasses(toCompile);
+ * }
+ *
+ * build.finish();
+ * }
+ *
+ * <p>After each compilation pass, {@link #processCompiledClasses(Set)} scans
+ * the freshly produced {@code .class} files, updates the dependency graph,
+ * and returns any additional source files that must be compiled in the next
+ * pass (cascade due to changed classes, or newly discovered dependencies).
+ * The loop converges in at most 2–3 passes in practice.
+ *
+ * <p>The engine persists its state as {@code incremental-state} in the
+ * {@code target/maven-status/maven-compiler-plugin/<outputDirName>/}
directory (outside the class
+ * output directory so it is not packaged into JARs).
+ *
+ * @see IncrementalState
+ */
+public class GraphIncrementalBuild {
+
+ /** Prefix used to distinguish module-info entries from regular type
entries in the state. */
+ static final String MODULE_PREFIX = BytecodeAnalyzer.MODULE_PREFIX;
+
+ private final Path outputDir;
+ private final Path buildDir;
+ private final Path stateFile;
+ private List<Path> classpathEntries;
+ private Set<Path> reactorModulePaths;
+ private List<Path> processorPath;
+ private ProcessorClassification processorClassification;
+
+ private IncrementalState previousState;
+ private IncrementalState state;
+
+ /** Package-private accessor for tests. */
+ IncrementalState getState() {
+ return state;
+ }
+
+ private Map<String, String> sourceHashes;
+ private Map<String, Long> sourceMtimes;
+ private List<Path> allSourceFiles;
+ private Set<String> allCompiled;
+ private boolean fullBuild;
+ private boolean useModulePrefixedPaths;
+ private String configHash = "";
+ private String rebuildCause;
+ private int totalSources;
+ /** Lazily populated on full builds; maps each output class file to its
simple top-level class name. */
+ private Map<Path, String> outputClassIndex;
+
+ public GraphIncrementalBuild(Path outputDir) {
+ this.outputDir = outputDir.toAbsolutePath();
+ this.buildDir = this.outputDir.getParent() != null ?
this.outputDir.getParent() : this.outputDir;
+ // Store state outside the output directory so it is not included in
the JAR.
+ // Use the same maven-status convention as the timestamp-based
strategy.
+ // Include the output directory name (e.g. "classes", "test-classes")
to avoid
+ // collisions between compile and testCompile executions.
+ String outputDirName = outputDir.getFileName().toString();
+ Path mavenStatus =
buildDir.resolve("maven-status").resolve("maven-compiler-plugin");
+ this.stateFile =
mavenStatus.resolve(outputDirName).resolve("incremental-state");
+ }
+
+ /**
+ * Sets classpath entries for external dependency tracking.
+ */
+ public void setClasspathEntries(List<Path> entries) {
+ this.classpathEntries = entries;
+ }
+
+ /**
+ * Marks specific classpath entries as reactor modules.
+ */
+ public void setReactorModulePaths(Set<Path> paths) {
+ this.reactorModulePaths = paths;
+ }
+
+ /**
+ * Sets the annotation processor classpath for processor classification.
+ * Entries are scanned for {@code
META-INF/maven/compiler/incremental.annotation.processors}
+ * and {@code META-INF/gradle/incremental.annotation.processors} to
determine
+ * whether each processor is {@link ProcessorType#ISOLATING},
+ * {@link ProcessorType#AGGREGATING}, or {@link ProcessorType#UNKNOWN}.
+ */
+ public void setProcessorPath(List<Path> processorPath) {
+ this.processorPath = processorPath;
+ this.processorClassification = new
ProcessorClassification(processorPath);
+ }
+
+ /**
+ * Indicates that class files are written under module-name subdirectories
+ * of the output directory (MODULE_SOURCE hierarchy). When set, stored
module
+ * names are used as path prefixes when deleting class files.
+ */
+ public void setUseModulePrefixedPaths(boolean useModulePrefixedPaths) {
+ this.useModulePrefixedPaths = useModulePrefixedPaths;
+ }
+
+ /**
+ * Sets a hash of compilation context configuration (e.g. compiler options
+ * and module-info-patch files). If this hash differs from the previous
+ * build, a full rebuild is triggered.
+ */
+ public void setConfigHash(String hash) {
+ this.configHash = hash != null ? hash : "";
+ }
+
+ /**
+ * Initializes the incremental build by scanning source files and comparing
+ * against the previous build's state.
+ *
+ * @param allSourceFiles all source files in this module
+ * @return the set of files that need compilation (may be all files for a
+ * full build, a subset for incremental, or empty if up-to-date)
+ */
+ public Set<Path> initialize(List<Path> allSourceFiles) throws IOException {
+ Files.createDirectories(outputDir);
+
+ this.allSourceFiles = allSourceFiles;
+ totalSources = allSourceFiles.size();
+ allCompiled = new TreeSet<>();
+ previousState = IncrementalState.load(stateFile);
+ sourceMtimes = new LinkedHashMap<>();
+ sourceHashes = hashSourceFiles(allSourceFiles, previousState,
sourceMtimes);
+
+ if (previousState == null) {
+ rebuildCause = "no previous build state";
+ return initFullBuild(allSourceFiles);
+ } else if (!configHash.equals(previousState.getConfigHash())) {
+ rebuildCause = "compilation configuration changed
(module-info-patch.maven or compiler options)";
+ return initFullBuild(allSourceFiles);
+ } else {
+ return initIncrementalBuild(allSourceFiles);
+ }
+ }
+
+ /**
+ * Processes the {@code .class} files produced by the last compilation
pass.
+ * Scans each class file to extract the dependency graph, records changes,
+ * and returns any additional source files that must be compiled in the
next pass.
+ *
+ * <p>The cascade logic: any compiled class whose content changed (new or
modified)
+ * triggers recompilation of all source files that depend on it (signature
or
+ * implementation consumers).
+ *
+ * @param compiledSourceFiles the source files that were passed to the
compiler in this round
+ * @return additional source files to compile (may be empty when fixpoint
is reached)
+ * @throws IOException if reading {@code .class} files fails
+ */
+ public Set<Path> processCompiledClasses(Set<Path> compiledSourceFiles)
throws IOException {
+ // Reset the class index so it is rebuilt fresh for each compilation
round
+ outputClassIndex = null;
+
+ // Scan .class files for the types produced from the compiled source
files
+ var results = new LinkedHashMap<String, SourceFileAnalysis>();
+ for (Path sourceFile : compiledSourceFiles) {
+ collectClassAnalyses(sourceFile, results);
+ }
+
+ // All compiled types cascade to their consumers (any change triggers
recompilation)
+ var changedTypes = new TreeSet<>(results.keySet());
+
+ // Update incremental state with this round's results
+ for (var entry : sourceHashes.entrySet()) {
+ if (allCompiled.contains(entry.getKey())) {
+ state.setSourceHash(entry.getKey(), entry.getValue());
+ }
+ }
+ for (var result : results.values()) {
+ String moduleName = useModulePrefixedPaths ? result.moduleName() :
"";
+ IncrementalState.TypeInfo typeInfo;
+ typeInfo = new IncrementalState.GraphTypeInfo(
+ result.sourceFile(), result.classDeps(),
result.annotationTypes(), moduleName);
+ state.setType(result.qualifiedName(), typeInfo);
+ }
+
+ if (fullBuild || changedTypes.isEmpty()) {
+ return Set.of();
+ }
+
+ // Detect module name changes — require a full rebuild
+ if (previousState != null && hasModuleNameChanged(state,
previousState)) {
+ return forceFullRebuild();
+ }
+
+ // Cascade: find all consumers of changed types (both signature and
implementation)
+ var cascade = new TreeSet<>(changedTypes);
+ for (String type : changedTypes) {
+ expandSignatureCascade(type, state, cascade);
+ }
+
+ var additionalFiles = new TreeSet<Path>();
+ for (String cascadedType : cascade) {
+ for (String consumer : state.getAllConsumers(cascadedType)) {
+ String sf = state.sourceFileFor(consumer);
+ if (sf != null && !allCompiled.contains(sf)) {
+ additionalFiles.add(Path.of(sf));
+ allCompiled.add(sf);
+ }
+ }
+ }
+
+ // Annotation processor cascade
+ additionalFiles.addAll(computeProcessorCascade());
+
+ return additionalFiles;
+ }
+
+ /**
+ * Scans the output directory for {@code .class} files produced from the
given source file,
+ * analyzes each one with {@link BytecodeAnalyzer}, and accumulates {@link
SourceFileAnalysis}
+ * records into {@code results}.
+ *
+ * <p>The source→class mapping is reconstructed by looking up types
previously recorded for
+ * this source file in the previous state, and by scanning the output
directory for class
+ * files whose name prefix matches the source file's simple name. This
covers both primary
+ * classes and inner/anonymous classes ({@code Foo$Bar.class}).
+ */
+ private void collectClassAnalyses(Path sourceFile, Map<String,
SourceFileAnalysis> results) throws IOException {
+ String sourceFilePath = sourceFile.toString();
+ String simpleSourceName = sourceFile.getFileName().toString(); // e.g.
"Model.java"
+
+ // Map from class file path → pre-computed analysis (null if not yet
analyzed).
+ // Reuses the analysis from the package-dir walk to avoid double
BytecodeAnalyzer.analyze calls.
+ var classFileCache = new LinkedHashMap<Path,
BytecodeAnalyzer.ClassAnalysis>();
+
+ // Types previously tracked for this source file — their .class files
may have moved
+ if (previousState != null) {
+ for (String type :
previousState.getTypesFromSource(sourceFilePath)) {
+ Path classFile = classFileFor(type,
previousState.getType(type));
+ if (Files.exists(classFile)) {
+ classFileCache.put(classFile, null);
+ }
+ // Also scan for inner classes (Foo$Bar.class etc.)
+ var innerFiles = new TreeSet<Path>();
+ addInnerClassFiles(classFile, innerFiles);
+ for (Path inner : innerFiles) {
+ classFileCache.putIfAbsent(inner, null);
+ }
+ }
+ }
+
+ // Walk output directory entries for this source's simple name
+ // (handles new types introduced in this compilation)
+ Path packageDir = inferPackageDir(sourceFile);
+ if (packageDir != null && Files.isDirectory(packageDir)) {
+ try (var stream = Files.list(packageDir)) {
+ stream.filter(p -> p.toString().endsWith(".class")).forEach(cf
-> {
+ try {
+ // Match by SourceFile attribute — covers primary
class, inner/anonymous
+ // classes (Foo$Bar.class), AND package-private
secondary top-level classes
+ // (FooHelper in Foo.java), without cross-package
simple-name collisions.
+ var a = BytecodeAnalyzer.analyze(cf);
+ if (simpleSourceName.equals(a.sourceFileName())) {
+ classFileCache.put(cf, a); // cache the analysis
for reuse below
+ }
+ } catch (IOException e) {
+ // Log at debug level — a corrupted or inaccessible
class file is silently
+ // skipped; the missing type entry will trigger a full
rebuild next time.
+ System.getLogger(GraphIncrementalBuild.class.getName())
+ .log(System.Logger.Level.DEBUG, "Failed to
analyze class file: {0}", cf);
Review Comment:
💡 **Low — `System.getLogger` for debug-level logging inconsistent with the
rest of the codebase**
The rest of the compiler plugin uses the Maven `Log` abstraction (via
`logger`). Using `System.getLogger` here means this debug output won't be
visible through Maven's `-X` debug flag and bypasses the project's logging
configuration.
Since `GraphIncrementalBuild` doesn't have a `Log` reference (it's a
standalone engine), consider either:
1. Passing a `Log` instance from the caller, or
2. Silently swallowing (the current behavior effectively does this since
DEBUG is suppressed by default), or
3. At minimum, documenting why `System.getLogger` is used here instead of
Maven logging.
##########
src/main/java/org/apache/maven/plugin/compiler/AbstractCompilerMojo.java:
##########
@@ -1398,7 +1423,21 @@ public Options parseParameters(final OptionChecker
compiler) {
*/
@SuppressWarnings("UseSpecificCatch")
private void compile(final JavaCompiler compiler, final Options
configuration) throws IOException {
- final ToolExecutor executor = createExecutor(null);
+ var executor = createExecutor(null);
+ if (!"timestamp".equalsIgnoreCase(incrementalStrategy) &&
!"graph".equalsIgnoreCase(incrementalStrategy)) {
+ throw new MojoException(
+ "Unknown incrementalStrategy: '" + incrementalStrategy +
"'. Valid values are: timestamp, graph");
+ }
+ if ("graph".equalsIgnoreCase(incrementalStrategy) &&
!Boolean.FALSE.equals(useIncrementalCompilation)) {
+ if (Runtime.version().feature() < 24) {
+ logger.warn("Graph incremental strategy requires JDK 24 or
later "
+ + "(running JDK " + Runtime.version().feature() + "). "
+ + "Falling back to timestamp strategy.");
+ } else {
+ executor.compileWithGraphIncremental(compiler, configuration,
this);
Review Comment:
💡 **Low — `Runtime.version().feature() < 24` is correct but consider
compile-time validation**
The runtime JDK version check works correctly for the multi-release JAR
fallback. However, there's a subtle interaction: the build requires JDK 25+
(enforced by maven-enforcer-plugin), but the _user_ running this plugin could
be on JDK 17-23 where the graph strategy silently falls back to timestamp.
The fallback is correctly logged as a warning, which is good. But consider
whether the `@since 4.0.0-beta-7` annotation in the Javadoc should also mention
the JDK 24+ runtime requirement for the graph strategy, since users reading the
docs won't necessarily know about the multi-release fallback.
##########
src/main/java/org/apache/maven/plugin/compiler/incremental/GraphIncrementalBuild.java:
##########
@@ -0,0 +1,910 @@
+/*
+ * 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.incremental;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeSet;
+import java.util.stream.Collectors;
+
+/**
+ * Dependency-graph-driven incremental build engine, designed for embedding
+ * in maven-compiler-plugin alongside the existing timestamp-based
+ * {@code IncrementalBuild}.
+ *
+ * <p>The plugin drives compilation; this class determines <em>what</em> to
+ * compile and performs post-compilation bytecode analysis to build the
+ * class-level dependency graph. Typical usage:
+ *
+ * {@snippet :
+ * var build = new GraphIncrementalBuild(outputDir);
+ * build.setProcessorPath(processorPath);
+ * build.setConfigHash(configHash);
+ *
+ * Set<Path> toCompile = build.initialize(allSourceFiles);
+ *
+ * while (!toCompile.isEmpty()) {
+ * compiler.compile(toCompile); // any compiler, any mode
+ * toCompile = build.processCompiledClasses(toCompile);
+ * }
+ *
+ * build.finish();
+ * }
+ *
+ * <p>After each compilation pass, {@link #processCompiledClasses(Set)} scans
+ * the freshly produced {@code .class} files, updates the dependency graph,
+ * and returns any additional source files that must be compiled in the next
+ * pass (cascade due to changed classes, or newly discovered dependencies).
+ * The loop converges in at most 2–3 passes in practice.
+ *
+ * <p>The engine persists its state as {@code incremental-state} in the
+ * {@code target/maven-status/maven-compiler-plugin/<outputDirName>/}
directory (outside the class
+ * output directory so it is not packaged into JARs).
+ *
+ * @see IncrementalState
+ */
+public class GraphIncrementalBuild {
+
+ /** Prefix used to distinguish module-info entries from regular type
entries in the state. */
+ static final String MODULE_PREFIX = BytecodeAnalyzer.MODULE_PREFIX;
+
+ private final Path outputDir;
+ private final Path buildDir;
+ private final Path stateFile;
+ private List<Path> classpathEntries;
+ private Set<Path> reactorModulePaths;
+ private List<Path> processorPath;
+ private ProcessorClassification processorClassification;
+
+ private IncrementalState previousState;
+ private IncrementalState state;
+
+ /** Package-private accessor for tests. */
+ IncrementalState getState() {
+ return state;
+ }
+
+ private Map<String, String> sourceHashes;
+ private Map<String, Long> sourceMtimes;
+ private List<Path> allSourceFiles;
+ private Set<String> allCompiled;
+ private boolean fullBuild;
+ private boolean useModulePrefixedPaths;
+ private String configHash = "";
+ private String rebuildCause;
+ private int totalSources;
+ /** Lazily populated on full builds; maps each output class file to its
simple top-level class name. */
+ private Map<Path, String> outputClassIndex;
+
+ public GraphIncrementalBuild(Path outputDir) {
+ this.outputDir = outputDir.toAbsolutePath();
+ this.buildDir = this.outputDir.getParent() != null ?
this.outputDir.getParent() : this.outputDir;
+ // Store state outside the output directory so it is not included in
the JAR.
+ // Use the same maven-status convention as the timestamp-based
strategy.
+ // Include the output directory name (e.g. "classes", "test-classes")
to avoid
+ // collisions between compile and testCompile executions.
+ String outputDirName = outputDir.getFileName().toString();
+ Path mavenStatus =
buildDir.resolve("maven-status").resolve("maven-compiler-plugin");
+ this.stateFile =
mavenStatus.resolve(outputDirName).resolve("incremental-state");
+ }
+
+ /**
+ * Sets classpath entries for external dependency tracking.
+ */
+ public void setClasspathEntries(List<Path> entries) {
+ this.classpathEntries = entries;
+ }
+
+ /**
+ * Marks specific classpath entries as reactor modules.
+ */
+ public void setReactorModulePaths(Set<Path> paths) {
+ this.reactorModulePaths = paths;
+ }
+
+ /**
+ * Sets the annotation processor classpath for processor classification.
+ * Entries are scanned for {@code
META-INF/maven/compiler/incremental.annotation.processors}
+ * and {@code META-INF/gradle/incremental.annotation.processors} to
determine
+ * whether each processor is {@link ProcessorType#ISOLATING},
+ * {@link ProcessorType#AGGREGATING}, or {@link ProcessorType#UNKNOWN}.
+ */
+ public void setProcessorPath(List<Path> processorPath) {
+ this.processorPath = processorPath;
+ this.processorClassification = new
ProcessorClassification(processorPath);
+ }
+
+ /**
+ * Indicates that class files are written under module-name subdirectories
+ * of the output directory (MODULE_SOURCE hierarchy). When set, stored
module
+ * names are used as path prefixes when deleting class files.
+ */
+ public void setUseModulePrefixedPaths(boolean useModulePrefixedPaths) {
+ this.useModulePrefixedPaths = useModulePrefixedPaths;
+ }
+
+ /**
+ * Sets a hash of compilation context configuration (e.g. compiler options
+ * and module-info-patch files). If this hash differs from the previous
+ * build, a full rebuild is triggered.
+ */
+ public void setConfigHash(String hash) {
+ this.configHash = hash != null ? hash : "";
+ }
+
+ /**
+ * Initializes the incremental build by scanning source files and comparing
+ * against the previous build's state.
+ *
+ * @param allSourceFiles all source files in this module
+ * @return the set of files that need compilation (may be all files for a
+ * full build, a subset for incremental, or empty if up-to-date)
+ */
+ public Set<Path> initialize(List<Path> allSourceFiles) throws IOException {
+ Files.createDirectories(outputDir);
+
+ this.allSourceFiles = allSourceFiles;
+ totalSources = allSourceFiles.size();
+ allCompiled = new TreeSet<>();
+ previousState = IncrementalState.load(stateFile);
+ sourceMtimes = new LinkedHashMap<>();
+ sourceHashes = hashSourceFiles(allSourceFiles, previousState,
sourceMtimes);
+
+ if (previousState == null) {
+ rebuildCause = "no previous build state";
+ return initFullBuild(allSourceFiles);
+ } else if (!configHash.equals(previousState.getConfigHash())) {
+ rebuildCause = "compilation configuration changed
(module-info-patch.maven or compiler options)";
+ return initFullBuild(allSourceFiles);
+ } else {
+ return initIncrementalBuild(allSourceFiles);
+ }
+ }
+
+ /**
+ * Processes the {@code .class} files produced by the last compilation
pass.
+ * Scans each class file to extract the dependency graph, records changes,
+ * and returns any additional source files that must be compiled in the
next pass.
+ *
+ * <p>The cascade logic: any compiled class whose content changed (new or
modified)
+ * triggers recompilation of all source files that depend on it (signature
or
+ * implementation consumers).
+ *
+ * @param compiledSourceFiles the source files that were passed to the
compiler in this round
+ * @return additional source files to compile (may be empty when fixpoint
is reached)
+ * @throws IOException if reading {@code .class} files fails
+ */
+ public Set<Path> processCompiledClasses(Set<Path> compiledSourceFiles)
throws IOException {
+ // Reset the class index so it is rebuilt fresh for each compilation
round
+ outputClassIndex = null;
+
+ // Scan .class files for the types produced from the compiled source
files
+ var results = new LinkedHashMap<String, SourceFileAnalysis>();
+ for (Path sourceFile : compiledSourceFiles) {
+ collectClassAnalyses(sourceFile, results);
+ }
+
+ // All compiled types cascade to their consumers (any change triggers
recompilation)
+ var changedTypes = new TreeSet<>(results.keySet());
+
+ // Update incremental state with this round's results
+ for (var entry : sourceHashes.entrySet()) {
+ if (allCompiled.contains(entry.getKey())) {
+ state.setSourceHash(entry.getKey(), entry.getValue());
+ }
+ }
+ for (var result : results.values()) {
+ String moduleName = useModulePrefixedPaths ? result.moduleName() :
"";
+ IncrementalState.TypeInfo typeInfo;
+ typeInfo = new IncrementalState.GraphTypeInfo(
+ result.sourceFile(), result.classDeps(),
result.annotationTypes(), moduleName);
+ state.setType(result.qualifiedName(), typeInfo);
+ }
+
+ if (fullBuild || changedTypes.isEmpty()) {
+ return Set.of();
+ }
+
+ // Detect module name changes — require a full rebuild
+ if (previousState != null && hasModuleNameChanged(state,
previousState)) {
+ return forceFullRebuild();
+ }
+
+ // Cascade: find all consumers of changed types (both signature and
implementation)
+ var cascade = new TreeSet<>(changedTypes);
+ for (String type : changedTypes) {
+ expandSignatureCascade(type, state, cascade);
+ }
+
+ var additionalFiles = new TreeSet<Path>();
+ for (String cascadedType : cascade) {
+ for (String consumer : state.getAllConsumers(cascadedType)) {
+ String sf = state.sourceFileFor(consumer);
+ if (sf != null && !allCompiled.contains(sf)) {
+ additionalFiles.add(Path.of(sf));
+ allCompiled.add(sf);
+ }
+ }
+ }
+
+ // Annotation processor cascade
+ additionalFiles.addAll(computeProcessorCascade());
+
+ return additionalFiles;
+ }
+
+ /**
+ * Scans the output directory for {@code .class} files produced from the
given source file,
+ * analyzes each one with {@link BytecodeAnalyzer}, and accumulates {@link
SourceFileAnalysis}
+ * records into {@code results}.
+ *
+ * <p>The source→class mapping is reconstructed by looking up types
previously recorded for
+ * this source file in the previous state, and by scanning the output
directory for class
+ * files whose name prefix matches the source file's simple name. This
covers both primary
+ * classes and inner/anonymous classes ({@code Foo$Bar.class}).
+ */
+ private void collectClassAnalyses(Path sourceFile, Map<String,
SourceFileAnalysis> results) throws IOException {
+ String sourceFilePath = sourceFile.toString();
+ String simpleSourceName = sourceFile.getFileName().toString(); // e.g.
"Model.java"
+
+ // Map from class file path → pre-computed analysis (null if not yet
analyzed).
+ // Reuses the analysis from the package-dir walk to avoid double
BytecodeAnalyzer.analyze calls.
+ var classFileCache = new LinkedHashMap<Path,
BytecodeAnalyzer.ClassAnalysis>();
+
+ // Types previously tracked for this source file — their .class files
may have moved
+ if (previousState != null) {
+ for (String type :
previousState.getTypesFromSource(sourceFilePath)) {
+ Path classFile = classFileFor(type,
previousState.getType(type));
+ if (Files.exists(classFile)) {
+ classFileCache.put(classFile, null);
+ }
+ // Also scan for inner classes (Foo$Bar.class etc.)
+ var innerFiles = new TreeSet<Path>();
+ addInnerClassFiles(classFile, innerFiles);
+ for (Path inner : innerFiles) {
+ classFileCache.putIfAbsent(inner, null);
+ }
+ }
+ }
+
+ // Walk output directory entries for this source's simple name
+ // (handles new types introduced in this compilation)
+ Path packageDir = inferPackageDir(sourceFile);
+ if (packageDir != null && Files.isDirectory(packageDir)) {
+ try (var stream = Files.list(packageDir)) {
+ stream.filter(p -> p.toString().endsWith(".class")).forEach(cf
-> {
+ try {
+ // Match by SourceFile attribute — covers primary
class, inner/anonymous
+ // classes (Foo$Bar.class), AND package-private
secondary top-level classes
+ // (FooHelper in Foo.java), without cross-package
simple-name collisions.
+ var a = BytecodeAnalyzer.analyze(cf);
+ if (simpleSourceName.equals(a.sourceFileName())) {
+ classFileCache.put(cf, a); // cache the analysis
for reuse below
+ }
+ } catch (IOException e) {
+ // Log at debug level — a corrupted or inaccessible
class file is silently
+ // skipped; the missing type entry will trigger a full
rebuild next time.
+ System.getLogger(GraphIncrementalBuild.class.getName())
+ .log(System.Logger.Level.DEBUG, "Failed to
analyze class file: {0}", cf);
+ }
+ });
+ }
+ } else {
+ // No previous state for this source (full build or new file in
incremental build):
+ // use the cached class index keyed by SourceFile attribute value
(simple source name,
+ // e.g. "Foo.java"). This correctly associates package-private
secondary types and
+ // avoids cross-package simple-name collisions.
+ for (var entry : getFullBuildClassIndex().entrySet()) {
+ if (simpleSourceName.equals(entry.getValue())) {
+ classFileCache.putIfAbsent(entry.getKey(), null);
+ }
+ }
+ }
+
+ // Special case: module-info.class — check both flat and
module-prefixed locations
+ if (sourceFile.getFileName().toString().equals("module-info.java")) {
+ // Flat layout: outputDir/module-info.class
+ Path flat = outputDir.resolve("module-info.class");
+ if (Files.exists(flat)) {
+ classFileCache.putIfAbsent(flat, null);
+ }
+ // MODULE_SOURCE layout: outputDir/<moduleName>/module-info.class
+ if (useModulePrefixedPaths && previousState != null) {
+ // Find the module name from previous state
+ for (String type :
previousState.getTypesFromSource(sourceFile.toString())) {
+ if (type.startsWith(MODULE_PREFIX)) {
+ String modName =
type.substring(MODULE_PREFIX.length());
+ Path modInfo =
outputDir.resolve(modName).resolve("module-info.class");
+ if (Files.exists(modInfo)) {
+ classFileCache.putIfAbsent(modInfo, null);
+ }
+ break;
+ }
+ }
+ }
+ }
+
+ // Final pass: analyze each class file (reusing cached analysis where
available)
+ for (var cacheEntry : classFileCache.entrySet()) {
+ Path classFile = cacheEntry.getKey();
+ if (!Files.exists(classFile)) {
+ continue;
+ }
+ try {
+ // Graph analysis: class-level deps only (constant pool scan)
+ // We still need className, sourceFileName, moduleName,
annotationTypes
+ // from a lightweight analysis — use analyzeGraph for deps but
full analyze
+ // for metadata (still one parse of the classfile via the
classfile API).
+ var analysis =
+ cacheEntry.getValue() != null ? cacheEntry.getValue()
: BytecodeAnalyzer.analyze(classFile);
+ String sourceFilePath2 = analysis.isModuleInfo()
+ ? sourceFilePath
+ : resolveSourceFile(analysis.className(),
sourceFilePath);
+ Set<String> classDeps = unionDeps(analysis.signatureTypes(),
analysis.implementationTypes());
+ var sfa = new SourceFileAnalysis(
+ analysis.className(),
+ sourceFilePath2,
+ classDeps,
+ analysis.annotationTypes(),
+ analysis.moduleName());
+ results.put(analysis.className(), sfa);
+ } catch (IOException e) {
+ // Skip unreadable class files — they'll be caught at compile
time
+ }
+ }
+ }
+
+ /** Returns the union of two sets (both may be empty). */
+ private static Set<String> unionDeps(Set<String> a, Set<String> b) {
+ if (a.isEmpty()) {
+ return b;
+ }
+ if (b.isEmpty()) {
+ return a;
+ }
+ var result = new java.util.HashSet<String>(a);
+ result.addAll(b);
+ return Set.copyOf(result);
+ }
+
+ private Map<Path, String> getFullBuildClassIndex() throws IOException {
+ if (outputClassIndex == null) {
+ outputClassIndex = new LinkedHashMap<>();
+ if (Files.isDirectory(outputDir)) {
+ try (var walk = Files.walk(outputDir)) {
+ for (Path cf :
+ (Iterable<Path>) walk.filter(p ->
p.toString().endsWith(".class"))::iterator) {
+ try {
+ var analysis = BytecodeAnalyzer.analyze(cf);
+ // Use the SourceFile attribute value — correctly
handles package-private
+ // secondary types in the same file (e.g.
FooHelper in Foo.java → "Foo.java").
+ String sfName = analysis.sourceFileName();
+ if (!sfName.isEmpty()) {
+ outputClassIndex.put(cf, sfName);
+ }
+ } catch (IOException e) {
+ // best effort — skip unreadable class files
+ }
+ }
+ }
+ }
+ }
+ return outputClassIndex;
+ }
+
+ /** Returns the expected .class file path for a type, accounting for
module-prefixed output dirs. */
+ private Path classFileFor(String qualifiedName, IncrementalState.TypeInfo
info) {
+ String moduleName = info != null ? info.moduleName() : "";
+ Path base = (useModulePrefixedPaths && !moduleName.isEmpty()) ?
outputDir.resolve(moduleName) : outputDir;
+ if (qualifiedName.startsWith(MODULE_PREFIX)) {
+ return base.resolve("module-info.class");
+ }
+ return base.resolve(qualifiedName.replace('.', '/') + ".class");
+ }
+
+ private static void addInnerClassFiles(Path primaryClassFile, Set<Path>
result) {
+ if (!Files.exists(primaryClassFile)) {
+ return;
+ }
+ Path dir = primaryClassFile.getParent();
+ if (dir == null || !Files.isDirectory(dir)) {
+ return;
+ }
+ String prefix =
primaryClassFile.getFileName().toString().replace(".class", "$");
+ try (var stream = Files.list(dir)) {
+ stream.filter(p -> p.getFileName().toString().startsWith(prefix)
+ && p.getFileName().toString().endsWith(".class"))
+ .forEach(result::add);
+ } catch (IOException e) {
+ // Best effort
+ }
+ }
+
+ /**
+ * Infers the package directory in the output tree for the given source
file,
+ * based on the types previously recorded for it in the incremental state.
+ * Accounts for module-prefixed output directories when {@code
useModulePrefixedPaths} is true.
+ */
+ private Path inferPackageDir(Path sourceFile) {
+ if (previousState == null) {
+ return null;
+ }
+ String sourceFilePath = sourceFile.toString();
+ for (String type : previousState.getTypesFromSource(sourceFilePath)) {
+ if (!type.startsWith(MODULE_PREFIX)) {
+ String pkg = type.contains(".")
+ ? type.substring(0,
type.lastIndexOf('.')).replace('.', '/')
+ : "";
+ // Determine base dir: for module-prefixed output, classes
live under outputDir/<module>/
+ var info = previousState.getType(type);
+ String moduleName = (useModulePrefixedPaths && info != null) ?
info.moduleName() : "";
+ Path base = (!moduleName.isEmpty()) ?
outputDir.resolve(moduleName) : outputDir;
+ return pkg.isEmpty() ? base : base.resolve(pkg);
+ }
+ }
+ return null;
+ }
+
+ /** Returns the source file to associate with a class, preferring the
known path if available. */
+ private String resolveSourceFile(String className, String
defaultSourceFile) {
+ if (state != null) {
+ String sf = state.sourceFileFor(className);
+ if (sf != null) {
+ return sf;
+ }
+ }
+ return defaultSourceFile;
+ }
+
+ /**
+ * Finalizes the incremental build and persists the state.
+ */
+ public void finish() throws IOException {
+ if (state == null) {
+ return;
+ }
+
+ // Persist source mtimes for the next build's mtime-first optimization
+ for (var entry : sourceMtimes.entrySet()) {
+ state.setSourceMtime(entry.getKey(), entry.getValue());
+ }
+
+ state.setConfigHash(configHash);
+ state.save(stateFile);
+ }
+
+ /**
+ * Invalidates the incremental state after a compilation failure.
+ * Deletes the state file so the next build starts fresh, avoiding
+ * a broken output directory where class files were deleted but
+ * not regenerated.
+ */
+ public void invalidate() {
+ try {
+ Files.deleteIfExists(stateFile);
+ } catch (IOException e) {
+ // Best effort — a missing state file just triggers a full rebuild
+ }
+ }
+
+ /**
+ * Returns whether this was a full build (no previous state).
+ */
+ public boolean isFullBuild() {
+ return fullBuild;
+ }
+
+ /**
+ * Returns the total number of files compiled across all rounds.
+ */
+ public int compiledCount() {
+ return allCompiled.size();
+ }
+
+ /**
+ * Returns the total number of files that were unchanged.
+ */
+ public int unchangedCount() {
+ return totalSources - allCompiled.size();
+ }
+
+ /**
+ * Returns a human-readable description of why recompilation was triggered,
+ * or {@code null} if no rebuild is needed.
+ */
+ public String getRebuildCause() {
+ return rebuildCause;
+ }
+
+ // --- Initialization ---
+
+ private Set<Path> initFullBuild(List<Path> allSourceFiles) {
+ fullBuild = true;
+ state = new IncrementalState();
+ state.setClasspathIdentities(computeCurrentJarIdentities());
+
+ var files = new TreeSet<Path>();
+ for (Path f : allSourceFiles) {
+ files.add(f);
+ allCompiled.add(f.toString());
+ }
+ return files;
+ }
+
+ private Set<Path> initIncrementalBuild(List<Path> allSourceFiles) {
+ fullBuild = false;
+ state = previousState.copy();
+
+ // Detect source changes
+ var changedFiles = new TreeSet<String>();
+ var newFiles = new TreeSet<String>();
+ var deletedFiles = new
TreeSet<>(previousState.getSourceHashes().keySet());
+
+ for (var entry : sourceHashes.entrySet()) {
+ String path = entry.getKey();
+ String hash = entry.getValue();
+ deletedFiles.remove(path);
+
+ String previousHash = previousState.getSourceHash(path);
+ if (previousHash == null) {
+ newFiles.add(path);
+ } else if (!hash.equals(previousHash)) {
+ changedFiles.add(path);
+ }
+ }
+
+ // Check external classpath changes
+ Set<String> externallyInvalidated = checkExternalClasspathChanges();
+
+ if (changedFiles.isEmpty() && newFiles.isEmpty() &&
deletedFiles.isEmpty() && externallyInvalidated.isEmpty()) {
+ return Set.of();
+ }
+
+ // Build rebuild cause description
+ var causes = new ArrayList<String>();
+ if (!changedFiles.isEmpty()) {
+ causes.add(changedFiles.size() + " changed");
+ }
+ if (!newFiles.isEmpty()) {
+ causes.add(newFiles.size() + " new");
+ }
+ if (!deletedFiles.isEmpty()) {
+ causes.add(deletedFiles.size() + " deleted");
+ }
+ if (!externallyInvalidated.isEmpty()) {
+ causes.add(externallyInvalidated.size() + " invalidated by
dependency changes");
+ }
+ rebuildCause = String.join(", ", causes);
+
+ // Build initial recompilation set
+ var toRecompile = new TreeSet<String>();
+ toRecompile.addAll(changedFiles);
+ toRecompile.addAll(newFiles);
+ toRecompile.addAll(externallyInvalidated);
+
+ // Consumers of deleted types
+ for (String deleted : deletedFiles) {
+ for (String type : previousState.getTypesFromSource(deleted)) {
+ for (String consumer : previousState.getAllConsumers(type)) {
+ String sf = previousState.sourceFileFor(consumer);
+ if (sf != null) {
+ toRecompile.add(sf);
+ }
+ }
+ deleteClassFile(type, previousState.getType(type));
+ }
+ state.removeSource(deleted);
+ }
+
+ // Clear stale type entries and class files for files about to be
recompiled —
+ // handles cases where a source file previously defined multiple types
+ // (including inner/nested classes) but now defines fewer
+ for (String sourceFile : toRecompile) {
+ for (String type : previousState.getTypesFromSource(sourceFile)) {
+ deleteClassFile(type, previousState.getType(type));
+ }
+ state.removeTypesForSource(sourceFile);
+ }
+
+ allCompiled.addAll(toRecompile);
+ var result = new TreeSet<Path>();
+ for (String s : toRecompile) {
+ result.add(Path.of(s));
+ }
+ return result;
+ }
+
+ // --- Annotation processor handling ---
+
+ /**
+ * Determines additional files to compile based on annotation processor
classification.
+ * Called during incremental builds when annotated sources are in the
compile set.
+ *
+ * <ul>
+ * <li>ISOLATING: no extra files needed (default, current behavior
works)</li>
+ * <li>AGGREGATING: all sources carrying the processor's trigger
annotations</li>
+ * <li>UNKNOWN: all sources (conservative full rebuild)</li>
+ * </ul>
+ */
+ private Set<Path> computeProcessorCascade() {
+ if (processorClassification == null) {
+ return Set.of();
+ }
+
+ // Collect annotation types from types we just compiled
+ var compiledAnnotations = new TreeSet<String>();
+ for (var entry : state.getTypes().entrySet()) {
+ if (allCompiled.contains(state.sourceFileFor(entry.getKey()))) {
+ compiledAnnotations.addAll(entry.getValue().annotationTypes());
+ }
+ }
+
+ if (compiledAnnotations.isEmpty()) {
+ return Set.of();
+ }
+
+ // Check if any compiled annotation triggers an AGGREGATING or UNKNOWN
processor
+ boolean hasUnknown = false;
+ boolean hasAggregating = false;
+
+ // Use worst-case classification from the processor path
+ var allAnnotations = state.getAllAnnotationTypes();
+ if (allAnnotations.isEmpty()) {
+ return Set.of();
+ }
+
+ // Check if any processors on the path are UNKNOWN or AGGREGATING
+ var classificationMap = processorClassification.getClassifications();
+ for (var entry : classificationMap.entrySet()) {
+ if (entry.getValue() == ProcessorType.UNKNOWN) {
+ hasUnknown = true;
+ } else if (entry.getValue() == ProcessorType.AGGREGATING) {
+ hasAggregating = true;
+ }
+ }
+
+ // If no processors are classified at all but processor path is set,
+ // we can't know what annotations they handle — conservative approach
+ if (classificationMap.isEmpty() && processorPath != null &&
!processorPath.isEmpty()) {
+ hasUnknown = true;
+ }
+
+ var additionalFiles = new TreeSet<Path>();
+
+ if (hasUnknown) {
+ // UNKNOWN: recompile all source files
+ for (Path sf : allSourceFiles) {
+ if (!allCompiled.contains(sf.toString())) {
+ additionalFiles.add(sf);
+ allCompiled.add(sf.toString());
+ }
+ }
+ } else if (hasAggregating) {
+ // AGGREGATING: recompile all annotated sources
+ Set<String> annotatedFiles =
state.getSourceFilesWithAnnotations(allAnnotations);
+ for (String sf : annotatedFiles) {
+ if (!allCompiled.contains(sf)) {
+ additionalFiles.add(Path.of(sf));
+ allCompiled.add(sf);
+ }
+ }
+ }
+ // ISOLATING: no extra files needed
+
+ return additionalFiles;
+ }
+
+ // --- Utility ---
+
+ private static boolean hasModuleNameChanged(IncrementalState current,
IncrementalState previous) {
+ var currentModules = current.getTypes().keySet().stream()
+ .filter(k -> k.startsWith(MODULE_PREFIX))
+ .collect(Collectors.toSet());
+ var previousModules = previous.getTypes().keySet().stream()
+ .filter(k -> k.startsWith(MODULE_PREFIX))
+ .collect(Collectors.toSet());
+ return !currentModules.equals(previousModules);
+ }
+
+ private Set<Path> forceFullRebuild() {
+ // Delete old class files for types not yet recompiled
+ for (var entry : previousState.getTypes().entrySet()) {
+ String sf = entry.getValue().sourceFile();
+ if (sf != null && !allCompiled.contains(sf)) {
+ deleteClassFile(entry.getKey(), entry.getValue());
+ }
+ }
+ var additionalFiles = new TreeSet<Path>();
+ for (Path sf : allSourceFiles) {
+ if (!allCompiled.contains(sf.toString())) {
+ additionalFiles.add(sf);
+ allCompiled.add(sf.toString());
+ }
+ }
+ return additionalFiles;
+ }
+
+ private void expandSignatureCascade(String startType, IncrementalState
state, Set<String> result) {
+ var worklist = new ArrayDeque<String>();
+ worklist.add(startType);
+ while (!worklist.isEmpty()) {
+ String type = worklist.poll();
+ for (String consumer : state.getSignatureConsumers(type)) {
+ if (result.add(consumer)) {
+ worklist.add(consumer);
+ }
+ }
+ }
+ }
+
+ private void deleteClassFile(String qualifiedName,
IncrementalState.TypeInfo info) {
+ String moduleName = info != null ? info.moduleName() : "";
+ Path baseDir = moduleName.isEmpty() ? outputDir :
outputDir.resolve(moduleName);
+
+ if (qualifiedName.startsWith(MODULE_PREFIX)) {
+ Path moduleInfoClass = baseDir.resolve("module-info.class");
+ try {
+ Files.deleteIfExists(moduleInfoClass);
+ } catch (IOException e) {
+ throw new UncheckedIOException("Failed to delete
module-info.class", e);
+ }
+ return;
+ }
+ Path classFile = baseDir.resolve(qualifiedName.replace('.', '/') +
".class");
+ try {
+ Files.deleteIfExists(classFile);
+ // Also clean up inner/nested class files (Foo$Bar.class,
Foo$Bar$Baz.class, etc.)
+ // These are generated by javac alongside the top-level class file.
+ Path classDir = classFile.getParent();
+ String simplePrefix =
classFile.getFileName().toString().replace(".class", "$");
+ if (Files.isDirectory(classDir)) {
+ try (var stream = Files.list(classDir)) {
+ stream.filter(p ->
p.getFileName().toString().startsWith(simplePrefix)
+ &&
p.getFileName().toString().endsWith(".class"))
+ .forEach(p -> {
+ try {
+ Files.deleteIfExists(p);
+ } catch (IOException ex) {
+ // Best effort — stale inner class files
are harmless
+ }
+ });
+ }
+ }
+ } catch (IOException e) {
+ throw new UncheckedIOException("Failed to delete stale class file:
" + classFile, e);
+ }
+ }
+
+ /**
+ * Computes SHA-256 hashes for the given source files, using an mtime-first
+ * short-circuit: if a file's last-modified time matches the value stored
in
+ * {@code prev}, its previously stored hash is reused without reading the
+ * file's content. This avoids redundant I/O on the common no-change case,
+ * particularly beneficial for large source trees.
+ *
+ * @param files the source files to process
+ * @param prev previous build's state (may be {@code null})
+ * @param mtimes output map populated with each file's observed mtime
(millis)
+ * @return map from file path string to SHA-256 content hash
+ */
+ private static Map<String, String> hashSourceFiles(
+ List<Path> files, IncrementalState prev, Map<String, Long> mtimes)
throws IOException {
+ var hashes = new LinkedHashMap<String, String>();
+ for (Path file : files) {
+ String path = file.toString();
+ BasicFileAttributes attrs = Files.readAttributes(file,
BasicFileAttributes.class);
+ long mtime = attrs.lastModifiedTime().toMillis();
+ mtimes.put(path, mtime);
+
+ if (prev != null) {
+ var storedMtime = prev.getSourceMtime(path);
+ if (storedMtime.isPresent() && storedMtime.getAsLong() ==
mtime) {
+ String storedHash = prev.getSourceHash(path);
+ if (storedHash != null) {
+ // mtime unchanged — reuse stored hash, skip reading
file content
+ hashes.put(path, storedHash);
+ continue;
+ }
+ }
+ }
+
+ hashes.put(path, Sha256.hash(Files.readAllBytes(file)));
+ }
+ return hashes;
Review Comment:
💡 **Low — mtime optimization: document the TOCTOU assumption**
The mtime-first short-circuit reuses the stored hash without reading the
file content when the last-modified time matches. This is a valid optimization
for build tools (source files don't normally change without mtime changing),
but it has a known edge case: `rsync --size-only`, `touch -t`, or any tool that
preserves/resets mtime while changing content would cause the engine to miss
changes.
This is an acceptable trade-off (Gradle's incremental compiler makes the
same assumption), but it should be documented in the method's Javadoc since
it's a correctness-affecting optimization. A brief `@implNote` about the
assumption would suffice.
##########
src/main/java/org/apache/maven/plugin/compiler/incremental/Sha256.java:
##########
@@ -0,0 +1,58 @@
+/*
+ * 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.incremental;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.HexFormat;
+
+/**
+ * SHA-256 hashing utility shared by the bytecode analyzer implementations.
+ */
+public final class Sha256 {
+
+ private Sha256() {}
+
+ /**
+ * Returns the first 16 hex characters of the SHA-256 hash of {@code
input}.
+ *
+ * @param input the string to hash
+ * @return 16-character hex string
+ */
+ public static String hash(String input) {
+ return hash(input.getBytes(StandardCharsets.UTF_8));
+ }
+
+ /**
+ * Returns the first 16 hex characters of the SHA-256 hash of {@code
content}.
+ *
+ * @param content the bytes to hash
+ * @return 16-character hex string
+ */
+ public static String hash(byte[] content) {
+ try {
+ var md = MessageDigest.getInstance("SHA-256");
+ byte[] digest = md.digest(content);
+ return HexFormat.of().formatHex(digest).substring(0, 16);
Review Comment:
💡 **Low — SHA-256 truncation to 64 bits is fine for this use case**
Just noting for future readers: truncating to 16 hex characters (64 bits)
gives a birthday collision probability of ~N²/2^65. For a project with 100K
types, that's ~5×10⁻¹⁰. Perfectly safe for change detection (not security).
The only edge case: if the hash is ever used as a cache key or persisted
across builds in a context where collision = silent data corruption, this
truncation would need revisiting. Currently it's used for source content
hashing and config fingerprinting, both of which trigger a full rebuild on
mismatch, so a false positive (hash collision) would just cause an unnecessary
recompilation.
##########
src/main/java/org/apache/maven/plugin/compiler/ToolExecutor.java:
##########
@@ -915,6 +919,241 @@ private static boolean removeFirsts(Deque<Path> paths,
Integer count) {
}
}
+ /**
+ * Compiles using the dependency-graph incremental strategy. This method
handles the full
+ * lifecycle: determining what to compile, running javac, cascading on
changed classes,
+ * and persisting state.
+ *
+ * @param compiler the compiler
+ * @param configuration the options to give to the Java compiler
+ * @param mojo the MOJO for configuration access
+ * @throws IOException if an error occurred while reading or writing a file
+ * @throws MojoException if the compilation failed
+ */
+ void compileWithGraphIncremental(JavaCompiler compiler, Options
configuration, AbstractCompilerMojo mojo)
+ throws IOException {
+ var graphBuild = new GraphIncrementalBuild(outputDirectory);
+
+ // Collect annotation processor path for processor classification
+ var processorPaths = new ArrayList<Path>();
+ for (var entry : dependencies.entrySet()) {
+ if (entry.getKey() instanceof JavaPathType type) {
+ var location = type.location();
+ if (location.isPresent()
+ && (location.get() ==
StandardLocation.ANNOTATION_PROCESSOR_PATH
+ || location.get() ==
StandardLocation.ANNOTATION_PROCESSOR_MODULE_PATH)) {
+ processorPaths.addAll(entry.getValue());
+ }
+ }
+ }
+ if (!processorPaths.isEmpty()) {
+ graphBuild.setProcessorPath(processorPaths);
+ }
+
+ // Collect classpath entries for external dependency tracking
+ var classpathEntries = new ArrayList<Path>();
+ var reactorModulePaths = new LinkedHashSet<Path>();
+ for (var entry : dependencies.entrySet()) {
+ if (entry.getKey() instanceof JavaPathType type) {
+ var location = type.location();
+ if (location.isPresent() && location.get() ==
StandardLocation.CLASS_PATH) {
+ for (Path p : entry.getValue()) {
+ classpathEntries.add(p);
+ if (Files.isDirectory(p)) {
+ reactorModulePaths.add(p);
+ }
+ }
+ }
+ }
+ }
+ if (!classpathEntries.isEmpty()) {
+ graphBuild.setClasspathEntries(classpathEntries);
+ }
+ if (!reactorModulePaths.isEmpty()) {
+ graphBuild.setReactorModulePaths(reactorModulePaths);
+ }
+
+ // Hash module-info-patch.maven files for config change detection
+ graphBuild.setConfigHash(computeConfigHash(configuration));
+
+ // Collect all source file paths
+ var allSourcePaths = new ArrayList<Path>();
+ for (SourceFile sf : sourceFiles) {
+ allSourcePaths.add(sf.file);
+ }
+
+ Set<Path> toCompile = graphBuild.initialize(allSourcePaths);
+ if (toCompile.isEmpty()) {
+ logger.info("Nothing to compile - all classes are up to date
(graph strategy).");
+ graphBuild.finish();
+ return;
+ }
+
+ logger.info(
+ graphBuild.isFullBuild()
+ ? "Compiling " + toCompile.size() + " source file(s)
(graph: full build)."
+ : "Compiling " + toCompile.size() + " source file(s)
(graph: incremental).");
+ if (mojo.showCompilationChanges && graphBuild.getRebuildCause() !=
null) {
+ logger.info("Rebuild cause: " + graphBuild.getRebuildCause());
+ for (Path f : toCompile) {
+ logger.info(" " + f);
+ }
+ }
Review Comment:
⚠️ **Medium — `sourceFiles` field mutation is not thread-safe**
The `compileWithGraphIncremental` method mutates the `sourceFiles` field
(replacing it with a filtered subset) inside the compilation loop, then
restores it in a `finally` block. If any code path between the mutation and the
`finally` throws an unchecked exception that isn't caught, or if `sourceFiles`
is accessed concurrently (unlikely but not documented), this could leave the
executor in a corrupted state.
The `finally` block does restore, which is good. But the pattern of mutating
a shared field for a local purpose is inherently fragile. Consider using a
local variable and passing it through method parameters instead of mutating the
field.
```suggestion
var roundSourceFiles = originalSourceFiles.stream()
.filter(sf -> compileSet.contains(sf.file))
.collect(Collectors.toList());
if (roundSourceFiles.isEmpty()) {
break;
}
```
##########
pom.xml:
##########
@@ -188,10 +188,88 @@ under the License.
<excludes>
<exclude>unit/**</exclude>
</excludes>
+ <!--
+ Tests run against the exploded classes directory, not the
packaged JAR.
+ The JVM's multi-release JAR dispatch does not apply to
directories, so
+ the JDK 24+ BytecodeAnalyzer override must be placed before the
root stub
+ on the classpath. We prepend META-INF/versions/24/ via the
classesDirectory parameter
+ so it is resolved before target/classes/ by the bootstrap class
loader.
+ -->
+
<classesDirectory>${project.build.outputDirectory}/META-INF/versions/24</classesDirectory>
+ <additionalClasspathElements>
+
<additionalClasspathElement>${project.build.outputDirectory}</additionalClasspathElement>
+ </additionalClasspathElements>
</configuration>
</plugin>
</plugins>
</pluginManagement>
+ <plugins>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-enforcer-plugin</artifactId>
+ <executions>
+ <execution>
+ <id>enforce-java-build-version</id>
+ <goals>
+ <goal>enforce</goal>
+ </goals>
+ <configuration>
+ <rules>
+ <requireJavaVersion>
+ <!--
+ Building this plugin requires JDK 25+ so that the
java.lang.classfile
+ classes in src/main/java24/ can always be compiled (with
release=24).
+ The compiled plugin JAR targets JDK 17 for main sources
and JDK 24
+ for the classfile-backed bytecode analyzer, and runs on
any JVM >= 17.
+ -->
+ <version>[25,)</version>
+ </requireJavaVersion>
+ </rules>
+ </configuration>
+ </execution>
+ </executions>
+ </plugin>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-compiler-plugin</artifactId>
+ <executions>
+ <execution>
+ <!--
+ Compile the java.lang.classfile-based bytecode analyzer (JDK 24+
API)
+ into META-INF/versions/24/ to form a proper multi-release JAR.
+ On JDK 24+, the JVM automatically loads the versioned
BytecodeAnalyzer
+ which delegates directly to ClassfileClassAnalyzer (no
reflection needed).
+ On JDK < 24, the root BytecodeAnalyzer stub is loaded instead,
which
+ reports isAvailable()=false and triggers a fallback to the
timestamp strategy.
+ JDK 25 is required at build time so this execution always runs.
+ -->
+ <id>compile-java24</id>
+ <goals>
+ <goal>compile</goal>
+ </goals>
+ <phase>compile</phase>
+ <configuration>
+ <compileSourceRoots>
+
<compileSourceRoot>${project.basedir}/src/main/java24</compileSourceRoot>
+ </compileSourceRoots>
+ <release>24</release>
+
<outputDirectory>${project.build.outputDirectory}/META-INF/versions/24</outputDirectory>
+ </configuration>
+ </execution>
+ </executions>
+ </plugin>
+ <plugin>
Review Comment:
⚠️ **Medium — Build now requires JDK 25+, breaking contributor
accessibility**
The `<requireJavaVersion>[25,)</requireJavaVersion>` enforcer rule means no
contributor can build this plugin without JDK 25 (currently an EA release).
This is a significant barrier to contribution and CI matrix testing.
The PR description says "JDK 25 is required at build time so this execution
always runs", but is this truly necessary? If the `compile-java24` execution is
skipped on JDK < 25 (via a profile activation), the plugin could still be built
on JDK 17-24 — it would just not include the classfile-based analyzer in the
JAR, defaulting to the timestamp strategy at runtime.
Alternatively, consider using a CI-only profile that activates on JDK 25+ to
compile the java24 sources, and have the enforcer rule only in a release
profile.
##########
src/main/java/org/apache/maven/plugin/compiler/incremental/GraphIncrementalBuild.java:
##########
@@ -0,0 +1,910 @@
+/*
+ * 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.incremental;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeSet;
+import java.util.stream.Collectors;
+
+/**
+ * Dependency-graph-driven incremental build engine, designed for embedding
+ * in maven-compiler-plugin alongside the existing timestamp-based
+ * {@code IncrementalBuild}.
+ *
+ * <p>The plugin drives compilation; this class determines <em>what</em> to
+ * compile and performs post-compilation bytecode analysis to build the
+ * class-level dependency graph. Typical usage:
+ *
+ * {@snippet :
+ * var build = new GraphIncrementalBuild(outputDir);
+ * build.setProcessorPath(processorPath);
+ * build.setConfigHash(configHash);
+ *
+ * Set<Path> toCompile = build.initialize(allSourceFiles);
+ *
+ * while (!toCompile.isEmpty()) {
+ * compiler.compile(toCompile); // any compiler, any mode
+ * toCompile = build.processCompiledClasses(toCompile);
+ * }
+ *
+ * build.finish();
+ * }
+ *
+ * <p>After each compilation pass, {@link #processCompiledClasses(Set)} scans
+ * the freshly produced {@code .class} files, updates the dependency graph,
+ * and returns any additional source files that must be compiled in the next
+ * pass (cascade due to changed classes, or newly discovered dependencies).
+ * The loop converges in at most 2–3 passes in practice.
+ *
+ * <p>The engine persists its state as {@code incremental-state} in the
+ * {@code target/maven-status/maven-compiler-plugin/<outputDirName>/}
directory (outside the class
+ * output directory so it is not packaged into JARs).
+ *
+ * @see IncrementalState
+ */
+public class GraphIncrementalBuild {
+
+ /** Prefix used to distinguish module-info entries from regular type
entries in the state. */
+ static final String MODULE_PREFIX = BytecodeAnalyzer.MODULE_PREFIX;
+
+ private final Path outputDir;
+ private final Path buildDir;
+ private final Path stateFile;
+ private List<Path> classpathEntries;
+ private Set<Path> reactorModulePaths;
+ private List<Path> processorPath;
+ private ProcessorClassification processorClassification;
+
+ private IncrementalState previousState;
+ private IncrementalState state;
+
+ /** Package-private accessor for tests. */
+ IncrementalState getState() {
+ return state;
+ }
+
+ private Map<String, String> sourceHashes;
+ private Map<String, Long> sourceMtimes;
+ private List<Path> allSourceFiles;
+ private Set<String> allCompiled;
+ private boolean fullBuild;
+ private boolean useModulePrefixedPaths;
+ private String configHash = "";
+ private String rebuildCause;
+ private int totalSources;
+ /** Lazily populated on full builds; maps each output class file to its
simple top-level class name. */
+ private Map<Path, String> outputClassIndex;
+
+ public GraphIncrementalBuild(Path outputDir) {
+ this.outputDir = outputDir.toAbsolutePath();
+ this.buildDir = this.outputDir.getParent() != null ?
this.outputDir.getParent() : this.outputDir;
+ // Store state outside the output directory so it is not included in
the JAR.
+ // Use the same maven-status convention as the timestamp-based
strategy.
+ // Include the output directory name (e.g. "classes", "test-classes")
to avoid
+ // collisions between compile and testCompile executions.
+ String outputDirName = outputDir.getFileName().toString();
+ Path mavenStatus =
buildDir.resolve("maven-status").resolve("maven-compiler-plugin");
+ this.stateFile =
mavenStatus.resolve(outputDirName).resolve("incremental-state");
+ }
+
+ /**
+ * Sets classpath entries for external dependency tracking.
+ */
+ public void setClasspathEntries(List<Path> entries) {
+ this.classpathEntries = entries;
+ }
+
+ /**
+ * Marks specific classpath entries as reactor modules.
+ */
+ public void setReactorModulePaths(Set<Path> paths) {
+ this.reactorModulePaths = paths;
+ }
+
+ /**
+ * Sets the annotation processor classpath for processor classification.
+ * Entries are scanned for {@code
META-INF/maven/compiler/incremental.annotation.processors}
+ * and {@code META-INF/gradle/incremental.annotation.processors} to
determine
+ * whether each processor is {@link ProcessorType#ISOLATING},
+ * {@link ProcessorType#AGGREGATING}, or {@link ProcessorType#UNKNOWN}.
+ */
+ public void setProcessorPath(List<Path> processorPath) {
+ this.processorPath = processorPath;
+ this.processorClassification = new
ProcessorClassification(processorPath);
+ }
+
+ /**
+ * Indicates that class files are written under module-name subdirectories
+ * of the output directory (MODULE_SOURCE hierarchy). When set, stored
module
+ * names are used as path prefixes when deleting class files.
+ */
+ public void setUseModulePrefixedPaths(boolean useModulePrefixedPaths) {
+ this.useModulePrefixedPaths = useModulePrefixedPaths;
+ }
+
+ /**
+ * Sets a hash of compilation context configuration (e.g. compiler options
+ * and module-info-patch files). If this hash differs from the previous
+ * build, a full rebuild is triggered.
+ */
+ public void setConfigHash(String hash) {
+ this.configHash = hash != null ? hash : "";
+ }
+
+ /**
+ * Initializes the incremental build by scanning source files and comparing
+ * against the previous build's state.
+ *
+ * @param allSourceFiles all source files in this module
+ * @return the set of files that need compilation (may be all files for a
+ * full build, a subset for incremental, or empty if up-to-date)
+ */
+ public Set<Path> initialize(List<Path> allSourceFiles) throws IOException {
+ Files.createDirectories(outputDir);
+
+ this.allSourceFiles = allSourceFiles;
+ totalSources = allSourceFiles.size();
+ allCompiled = new TreeSet<>();
+ previousState = IncrementalState.load(stateFile);
+ sourceMtimes = new LinkedHashMap<>();
+ sourceHashes = hashSourceFiles(allSourceFiles, previousState,
sourceMtimes);
+
+ if (previousState == null) {
+ rebuildCause = "no previous build state";
+ return initFullBuild(allSourceFiles);
+ } else if (!configHash.equals(previousState.getConfigHash())) {
+ rebuildCause = "compilation configuration changed
(module-info-patch.maven or compiler options)";
+ return initFullBuild(allSourceFiles);
+ } else {
+ return initIncrementalBuild(allSourceFiles);
+ }
+ }
+
+ /**
+ * Processes the {@code .class} files produced by the last compilation
pass.
+ * Scans each class file to extract the dependency graph, records changes,
+ * and returns any additional source files that must be compiled in the
next pass.
+ *
+ * <p>The cascade logic: any compiled class whose content changed (new or
modified)
+ * triggers recompilation of all source files that depend on it (signature
or
+ * implementation consumers).
+ *
+ * @param compiledSourceFiles the source files that were passed to the
compiler in this round
+ * @return additional source files to compile (may be empty when fixpoint
is reached)
+ * @throws IOException if reading {@code .class} files fails
+ */
+ public Set<Path> processCompiledClasses(Set<Path> compiledSourceFiles)
throws IOException {
+ // Reset the class index so it is rebuilt fresh for each compilation
round
+ outputClassIndex = null;
+
+ // Scan .class files for the types produced from the compiled source
files
+ var results = new LinkedHashMap<String, SourceFileAnalysis>();
+ for (Path sourceFile : compiledSourceFiles) {
+ collectClassAnalyses(sourceFile, results);
+ }
+
+ // All compiled types cascade to their consumers (any change triggers
recompilation)
+ var changedTypes = new TreeSet<>(results.keySet());
+
+ // Update incremental state with this round's results
+ for (var entry : sourceHashes.entrySet()) {
+ if (allCompiled.contains(entry.getKey())) {
+ state.setSourceHash(entry.getKey(), entry.getValue());
+ }
+ }
+ for (var result : results.values()) {
+ String moduleName = useModulePrefixedPaths ? result.moduleName() :
"";
+ IncrementalState.TypeInfo typeInfo;
+ typeInfo = new IncrementalState.GraphTypeInfo(
+ result.sourceFile(), result.classDeps(),
result.annotationTypes(), moduleName);
+ state.setType(result.qualifiedName(), typeInfo);
+ }
+
+ if (fullBuild || changedTypes.isEmpty()) {
+ return Set.of();
+ }
+
+ // Detect module name changes — require a full rebuild
+ if (previousState != null && hasModuleNameChanged(state,
previousState)) {
+ return forceFullRebuild();
+ }
+
+ // Cascade: find all consumers of changed types (both signature and
implementation)
+ var cascade = new TreeSet<>(changedTypes);
+ for (String type : changedTypes) {
+ expandSignatureCascade(type, state, cascade);
+ }
+
+ var additionalFiles = new TreeSet<Path>();
+ for (String cascadedType : cascade) {
+ for (String consumer : state.getAllConsumers(cascadedType)) {
+ String sf = state.sourceFileFor(consumer);
+ if (sf != null && !allCompiled.contains(sf)) {
+ additionalFiles.add(Path.of(sf));
+ allCompiled.add(sf);
+ }
+ }
+ }
+
+ // Annotation processor cascade
+ additionalFiles.addAll(computeProcessorCascade());
+
+ return additionalFiles;
+ }
+
+ /**
+ * Scans the output directory for {@code .class} files produced from the
given source file,
+ * analyzes each one with {@link BytecodeAnalyzer}, and accumulates {@link
SourceFileAnalysis}
+ * records into {@code results}.
+ *
+ * <p>The source→class mapping is reconstructed by looking up types
previously recorded for
+ * this source file in the previous state, and by scanning the output
directory for class
+ * files whose name prefix matches the source file's simple name. This
covers both primary
+ * classes and inner/anonymous classes ({@code Foo$Bar.class}).
+ */
+ private void collectClassAnalyses(Path sourceFile, Map<String,
SourceFileAnalysis> results) throws IOException {
+ String sourceFilePath = sourceFile.toString();
+ String simpleSourceName = sourceFile.getFileName().toString(); // e.g.
"Model.java"
+
+ // Map from class file path → pre-computed analysis (null if not yet
analyzed).
+ // Reuses the analysis from the package-dir walk to avoid double
BytecodeAnalyzer.analyze calls.
+ var classFileCache = new LinkedHashMap<Path,
BytecodeAnalyzer.ClassAnalysis>();
+
+ // Types previously tracked for this source file — their .class files
may have moved
+ if (previousState != null) {
+ for (String type :
previousState.getTypesFromSource(sourceFilePath)) {
+ Path classFile = classFileFor(type,
previousState.getType(type));
+ if (Files.exists(classFile)) {
+ classFileCache.put(classFile, null);
+ }
+ // Also scan for inner classes (Foo$Bar.class etc.)
+ var innerFiles = new TreeSet<Path>();
+ addInnerClassFiles(classFile, innerFiles);
+ for (Path inner : innerFiles) {
+ classFileCache.putIfAbsent(inner, null);
+ }
+ }
+ }
+
+ // Walk output directory entries for this source's simple name
+ // (handles new types introduced in this compilation)
+ Path packageDir = inferPackageDir(sourceFile);
+ if (packageDir != null && Files.isDirectory(packageDir)) {
+ try (var stream = Files.list(packageDir)) {
+ stream.filter(p -> p.toString().endsWith(".class")).forEach(cf
-> {
+ try {
+ // Match by SourceFile attribute — covers primary
class, inner/anonymous
+ // classes (Foo$Bar.class), AND package-private
secondary top-level classes
+ // (FooHelper in Foo.java), without cross-package
simple-name collisions.
+ var a = BytecodeAnalyzer.analyze(cf);
+ if (simpleSourceName.equals(a.sourceFileName())) {
+ classFileCache.put(cf, a); // cache the analysis
for reuse below
+ }
+ } catch (IOException e) {
+ // Log at debug level — a corrupted or inaccessible
class file is silently
+ // skipped; the missing type entry will trigger a full
rebuild next time.
+ System.getLogger(GraphIncrementalBuild.class.getName())
+ .log(System.Logger.Level.DEBUG, "Failed to
analyze class file: {0}", cf);
+ }
+ });
+ }
+ } else {
+ // No previous state for this source (full build or new file in
incremental build):
+ // use the cached class index keyed by SourceFile attribute value
(simple source name,
+ // e.g. "Foo.java"). This correctly associates package-private
secondary types and
+ // avoids cross-package simple-name collisions.
+ for (var entry : getFullBuildClassIndex().entrySet()) {
+ if (simpleSourceName.equals(entry.getValue())) {
+ classFileCache.putIfAbsent(entry.getKey(), null);
+ }
+ }
+ }
+
+ // Special case: module-info.class — check both flat and
module-prefixed locations
+ if (sourceFile.getFileName().toString().equals("module-info.java")) {
+ // Flat layout: outputDir/module-info.class
+ Path flat = outputDir.resolve("module-info.class");
+ if (Files.exists(flat)) {
+ classFileCache.putIfAbsent(flat, null);
+ }
+ // MODULE_SOURCE layout: outputDir/<moduleName>/module-info.class
+ if (useModulePrefixedPaths && previousState != null) {
+ // Find the module name from previous state
+ for (String type :
previousState.getTypesFromSource(sourceFile.toString())) {
+ if (type.startsWith(MODULE_PREFIX)) {
+ String modName =
type.substring(MODULE_PREFIX.length());
+ Path modInfo =
outputDir.resolve(modName).resolve("module-info.class");
+ if (Files.exists(modInfo)) {
+ classFileCache.putIfAbsent(modInfo, null);
+ }
+ break;
+ }
+ }
+ }
+ }
+
+ // Final pass: analyze each class file (reusing cached analysis where
available)
+ for (var cacheEntry : classFileCache.entrySet()) {
+ Path classFile = cacheEntry.getKey();
+ if (!Files.exists(classFile)) {
+ continue;
+ }
+ try {
+ // Graph analysis: class-level deps only (constant pool scan)
+ // We still need className, sourceFileName, moduleName,
annotationTypes
+ // from a lightweight analysis — use analyzeGraph for deps but
full analyze
+ // for metadata (still one parse of the classfile via the
classfile API).
+ var analysis =
+ cacheEntry.getValue() != null ? cacheEntry.getValue()
: BytecodeAnalyzer.analyze(classFile);
+ String sourceFilePath2 = analysis.isModuleInfo()
+ ? sourceFilePath
+ : resolveSourceFile(analysis.className(),
sourceFilePath);
+ Set<String> classDeps = unionDeps(analysis.signatureTypes(),
analysis.implementationTypes());
+ var sfa = new SourceFileAnalysis(
+ analysis.className(),
+ sourceFilePath2,
+ classDeps,
+ analysis.annotationTypes(),
+ analysis.moduleName());
+ results.put(analysis.className(), sfa);
+ } catch (IOException e) {
+ // Skip unreadable class files — they'll be caught at compile
time
+ }
+ }
+ }
+
+ /** Returns the union of two sets (both may be empty). */
+ private static Set<String> unionDeps(Set<String> a, Set<String> b) {
+ if (a.isEmpty()) {
+ return b;
+ }
+ if (b.isEmpty()) {
+ return a;
+ }
+ var result = new java.util.HashSet<String>(a);
+ result.addAll(b);
+ return Set.copyOf(result);
+ }
+
+ private Map<Path, String> getFullBuildClassIndex() throws IOException {
+ if (outputClassIndex == null) {
+ outputClassIndex = new LinkedHashMap<>();
+ if (Files.isDirectory(outputDir)) {
+ try (var walk = Files.walk(outputDir)) {
+ for (Path cf :
+ (Iterable<Path>) walk.filter(p ->
p.toString().endsWith(".class"))::iterator) {
+ try {
+ var analysis = BytecodeAnalyzer.analyze(cf);
+ // Use the SourceFile attribute value — correctly
handles package-private
+ // secondary types in the same file (e.g.
FooHelper in Foo.java → "Foo.java").
+ String sfName = analysis.sourceFileName();
+ if (!sfName.isEmpty()) {
+ outputClassIndex.put(cf, sfName);
+ }
+ } catch (IOException e) {
+ // best effort — skip unreadable class files
+ }
+ }
+ }
+ }
+ }
+ return outputClassIndex;
+ }
+
+ /** Returns the expected .class file path for a type, accounting for
module-prefixed output dirs. */
+ private Path classFileFor(String qualifiedName, IncrementalState.TypeInfo
info) {
+ String moduleName = info != null ? info.moduleName() : "";
+ Path base = (useModulePrefixedPaths && !moduleName.isEmpty()) ?
outputDir.resolve(moduleName) : outputDir;
+ if (qualifiedName.startsWith(MODULE_PREFIX)) {
+ return base.resolve("module-info.class");
+ }
+ return base.resolve(qualifiedName.replace('.', '/') + ".class");
+ }
+
+ private static void addInnerClassFiles(Path primaryClassFile, Set<Path>
result) {
+ if (!Files.exists(primaryClassFile)) {
+ return;
+ }
+ Path dir = primaryClassFile.getParent();
+ if (dir == null || !Files.isDirectory(dir)) {
+ return;
+ }
+ String prefix =
primaryClassFile.getFileName().toString().replace(".class", "$");
+ try (var stream = Files.list(dir)) {
+ stream.filter(p -> p.getFileName().toString().startsWith(prefix)
+ && p.getFileName().toString().endsWith(".class"))
+ .forEach(result::add);
+ } catch (IOException e) {
+ // Best effort
+ }
+ }
+
+ /**
+ * Infers the package directory in the output tree for the given source
file,
+ * based on the types previously recorded for it in the incremental state.
+ * Accounts for module-prefixed output directories when {@code
useModulePrefixedPaths} is true.
+ */
+ private Path inferPackageDir(Path sourceFile) {
+ if (previousState == null) {
+ return null;
+ }
+ String sourceFilePath = sourceFile.toString();
+ for (String type : previousState.getTypesFromSource(sourceFilePath)) {
+ if (!type.startsWith(MODULE_PREFIX)) {
+ String pkg = type.contains(".")
+ ? type.substring(0,
type.lastIndexOf('.')).replace('.', '/')
+ : "";
+ // Determine base dir: for module-prefixed output, classes
live under outputDir/<module>/
+ var info = previousState.getType(type);
+ String moduleName = (useModulePrefixedPaths && info != null) ?
info.moduleName() : "";
+ Path base = (!moduleName.isEmpty()) ?
outputDir.resolve(moduleName) : outputDir;
+ return pkg.isEmpty() ? base : base.resolve(pkg);
+ }
+ }
+ return null;
+ }
+
+ /** Returns the source file to associate with a class, preferring the
known path if available. */
+ private String resolveSourceFile(String className, String
defaultSourceFile) {
+ if (state != null) {
+ String sf = state.sourceFileFor(className);
+ if (sf != null) {
+ return sf;
+ }
+ }
+ return defaultSourceFile;
+ }
+
+ /**
+ * Finalizes the incremental build and persists the state.
+ */
+ public void finish() throws IOException {
+ if (state == null) {
+ return;
+ }
+
+ // Persist source mtimes for the next build's mtime-first optimization
+ for (var entry : sourceMtimes.entrySet()) {
+ state.setSourceMtime(entry.getKey(), entry.getValue());
+ }
+
+ state.setConfigHash(configHash);
+ state.save(stateFile);
+ }
+
+ /**
+ * Invalidates the incremental state after a compilation failure.
+ * Deletes the state file so the next build starts fresh, avoiding
+ * a broken output directory where class files were deleted but
+ * not regenerated.
+ */
+ public void invalidate() {
+ try {
+ Files.deleteIfExists(stateFile);
+ } catch (IOException e) {
+ // Best effort — a missing state file just triggers a full rebuild
+ }
+ }
+
+ /**
+ * Returns whether this was a full build (no previous state).
+ */
+ public boolean isFullBuild() {
+ return fullBuild;
+ }
+
+ /**
+ * Returns the total number of files compiled across all rounds.
+ */
+ public int compiledCount() {
+ return allCompiled.size();
+ }
+
+ /**
+ * Returns the total number of files that were unchanged.
+ */
+ public int unchangedCount() {
+ return totalSources - allCompiled.size();
+ }
+
+ /**
+ * Returns a human-readable description of why recompilation was triggered,
+ * or {@code null} if no rebuild is needed.
+ */
+ public String getRebuildCause() {
+ return rebuildCause;
+ }
+
+ // --- Initialization ---
+
+ private Set<Path> initFullBuild(List<Path> allSourceFiles) {
+ fullBuild = true;
+ state = new IncrementalState();
+ state.setClasspathIdentities(computeCurrentJarIdentities());
+
+ var files = new TreeSet<Path>();
+ for (Path f : allSourceFiles) {
+ files.add(f);
+ allCompiled.add(f.toString());
+ }
+ return files;
+ }
+
+ private Set<Path> initIncrementalBuild(List<Path> allSourceFiles) {
+ fullBuild = false;
+ state = previousState.copy();
+
+ // Detect source changes
+ var changedFiles = new TreeSet<String>();
+ var newFiles = new TreeSet<String>();
+ var deletedFiles = new
TreeSet<>(previousState.getSourceHashes().keySet());
+
+ for (var entry : sourceHashes.entrySet()) {
+ String path = entry.getKey();
+ String hash = entry.getValue();
+ deletedFiles.remove(path);
+
+ String previousHash = previousState.getSourceHash(path);
+ if (previousHash == null) {
+ newFiles.add(path);
+ } else if (!hash.equals(previousHash)) {
+ changedFiles.add(path);
+ }
+ }
+
+ // Check external classpath changes
+ Set<String> externallyInvalidated = checkExternalClasspathChanges();
+
+ if (changedFiles.isEmpty() && newFiles.isEmpty() &&
deletedFiles.isEmpty() && externallyInvalidated.isEmpty()) {
+ return Set.of();
+ }
+
+ // Build rebuild cause description
+ var causes = new ArrayList<String>();
+ if (!changedFiles.isEmpty()) {
+ causes.add(changedFiles.size() + " changed");
+ }
+ if (!newFiles.isEmpty()) {
+ causes.add(newFiles.size() + " new");
+ }
+ if (!deletedFiles.isEmpty()) {
+ causes.add(deletedFiles.size() + " deleted");
+ }
+ if (!externallyInvalidated.isEmpty()) {
+ causes.add(externallyInvalidated.size() + " invalidated by
dependency changes");
+ }
+ rebuildCause = String.join(", ", causes);
+
+ // Build initial recompilation set
+ var toRecompile = new TreeSet<String>();
+ toRecompile.addAll(changedFiles);
+ toRecompile.addAll(newFiles);
+ toRecompile.addAll(externallyInvalidated);
+
+ // Consumers of deleted types
+ for (String deleted : deletedFiles) {
+ for (String type : previousState.getTypesFromSource(deleted)) {
+ for (String consumer : previousState.getAllConsumers(type)) {
+ String sf = previousState.sourceFileFor(consumer);
+ if (sf != null) {
+ toRecompile.add(sf);
+ }
+ }
+ deleteClassFile(type, previousState.getType(type));
+ }
+ state.removeSource(deleted);
+ }
+
+ // Clear stale type entries and class files for files about to be
recompiled —
+ // handles cases where a source file previously defined multiple types
+ // (including inner/nested classes) but now defines fewer
+ for (String sourceFile : toRecompile) {
+ for (String type : previousState.getTypesFromSource(sourceFile)) {
+ deleteClassFile(type, previousState.getType(type));
+ }
+ state.removeTypesForSource(sourceFile);
+ }
+
+ allCompiled.addAll(toRecompile);
+ var result = new TreeSet<Path>();
+ for (String s : toRecompile) {
+ result.add(Path.of(s));
+ }
+ return result;
+ }
+
+ // --- Annotation processor handling ---
+
+ /**
+ * Determines additional files to compile based on annotation processor
classification.
+ * Called during incremental builds when annotated sources are in the
compile set.
+ *
+ * <ul>
+ * <li>ISOLATING: no extra files needed (default, current behavior
works)</li>
+ * <li>AGGREGATING: all sources carrying the processor's trigger
annotations</li>
+ * <li>UNKNOWN: all sources (conservative full rebuild)</li>
+ * </ul>
+ */
+ private Set<Path> computeProcessorCascade() {
+ if (processorClassification == null) {
+ return Set.of();
+ }
+
+ // Collect annotation types from types we just compiled
+ var compiledAnnotations = new TreeSet<String>();
+ for (var entry : state.getTypes().entrySet()) {
+ if (allCompiled.contains(state.sourceFileFor(entry.getKey()))) {
+ compiledAnnotations.addAll(entry.getValue().annotationTypes());
+ }
+ }
+
+ if (compiledAnnotations.isEmpty()) {
+ return Set.of();
+ }
+
+ // Check if any compiled annotation triggers an AGGREGATING or UNKNOWN
processor
+ boolean hasUnknown = false;
+ boolean hasAggregating = false;
+
+ // Use worst-case classification from the processor path
+ var allAnnotations = state.getAllAnnotationTypes();
+ if (allAnnotations.isEmpty()) {
+ return Set.of();
+ }
+
+ // Check if any processors on the path are UNKNOWN or AGGREGATING
+ var classificationMap = processorClassification.getClassifications();
+ for (var entry : classificationMap.entrySet()) {
+ if (entry.getValue() == ProcessorType.UNKNOWN) {
+ hasUnknown = true;
+ } else if (entry.getValue() == ProcessorType.AGGREGATING) {
+ hasAggregating = true;
+ }
+ }
+
+ // If no processors are classified at all but processor path is set,
+ // we can't know what annotations they handle — conservative approach
+ if (classificationMap.isEmpty() && processorPath != null &&
!processorPath.isEmpty()) {
+ hasUnknown = true;
+ }
+
+ var additionalFiles = new TreeSet<Path>();
+
+ if (hasUnknown) {
+ // UNKNOWN: recompile all source files
+ for (Path sf : allSourceFiles) {
+ if (!allCompiled.contains(sf.toString())) {
+ additionalFiles.add(sf);
+ allCompiled.add(sf.toString());
+ }
+ }
+ } else if (hasAggregating) {
+ // AGGREGATING: recompile all annotated sources
+ Set<String> annotatedFiles =
state.getSourceFilesWithAnnotations(allAnnotations);
+ for (String sf : annotatedFiles) {
+ if (!allCompiled.contains(sf)) {
+ additionalFiles.add(Path.of(sf));
+ allCompiled.add(sf);
+ }
+ }
+ }
+ // ISOLATING: no extra files needed
+
+ return additionalFiles;
+ }
+
+ // --- Utility ---
+
+ private static boolean hasModuleNameChanged(IncrementalState current,
IncrementalState previous) {
+ var currentModules = current.getTypes().keySet().stream()
+ .filter(k -> k.startsWith(MODULE_PREFIX))
+ .collect(Collectors.toSet());
+ var previousModules = previous.getTypes().keySet().stream()
+ .filter(k -> k.startsWith(MODULE_PREFIX))
+ .collect(Collectors.toSet());
+ return !currentModules.equals(previousModules);
+ }
+
+ private Set<Path> forceFullRebuild() {
+ // Delete old class files for types not yet recompiled
+ for (var entry : previousState.getTypes().entrySet()) {
+ String sf = entry.getValue().sourceFile();
+ if (sf != null && !allCompiled.contains(sf)) {
+ deleteClassFile(entry.getKey(), entry.getValue());
+ }
+ }
+ var additionalFiles = new TreeSet<Path>();
+ for (Path sf : allSourceFiles) {
+ if (!allCompiled.contains(sf.toString())) {
+ additionalFiles.add(sf);
+ allCompiled.add(sf.toString());
+ }
+ }
+ return additionalFiles;
+ }
+
+ private void expandSignatureCascade(String startType, IncrementalState
state, Set<String> result) {
+ var worklist = new ArrayDeque<String>();
+ worklist.add(startType);
+ while (!worklist.isEmpty()) {
+ String type = worklist.poll();
+ for (String consumer : state.getSignatureConsumers(type)) {
+ if (result.add(consumer)) {
+ worklist.add(consumer);
+ }
+ }
+ }
+ }
+
+ private void deleteClassFile(String qualifiedName,
IncrementalState.TypeInfo info) {
+ String moduleName = info != null ? info.moduleName() : "";
+ Path baseDir = moduleName.isEmpty() ? outputDir :
outputDir.resolve(moduleName);
+
+ if (qualifiedName.startsWith(MODULE_PREFIX)) {
+ Path moduleInfoClass = baseDir.resolve("module-info.class");
+ try {
+ Files.deleteIfExists(moduleInfoClass);
+ } catch (IOException e) {
+ throw new UncheckedIOException("Failed to delete
module-info.class", e);
+ }
+ return;
+ }
+ Path classFile = baseDir.resolve(qualifiedName.replace('.', '/') +
".class");
+ try {
+ Files.deleteIfExists(classFile);
+ // Also clean up inner/nested class files (Foo$Bar.class,
Foo$Bar$Baz.class, etc.)
+ // These are generated by javac alongside the top-level class file.
+ Path classDir = classFile.getParent();
+ String simplePrefix =
classFile.getFileName().toString().replace(".class", "$");
+ if (Files.isDirectory(classDir)) {
+ try (var stream = Files.list(classDir)) {
+ stream.filter(p ->
p.getFileName().toString().startsWith(simplePrefix)
+ &&
p.getFileName().toString().endsWith(".class"))
+ .forEach(p -> {
+ try {
+ Files.deleteIfExists(p);
+ } catch (IOException ex) {
+ // Best effort — stale inner class files
are harmless
+ }
+ });
+ }
+ }
+ } catch (IOException e) {
+ throw new UncheckedIOException("Failed to delete stale class file:
" + classFile, e);
+ }
+ }
+
+ /**
+ * Computes SHA-256 hashes for the given source files, using an mtime-first
+ * short-circuit: if a file's last-modified time matches the value stored
in
+ * {@code prev}, its previously stored hash is reused without reading the
+ * file's content. This avoids redundant I/O on the common no-change case,
+ * particularly beneficial for large source trees.
+ *
+ * @param files the source files to process
+ * @param prev previous build's state (may be {@code null})
+ * @param mtimes output map populated with each file's observed mtime
(millis)
+ * @return map from file path string to SHA-256 content hash
+ */
+ private static Map<String, String> hashSourceFiles(
+ List<Path> files, IncrementalState prev, Map<String, Long> mtimes)
throws IOException {
+ var hashes = new LinkedHashMap<String, String>();
+ for (Path file : files) {
+ String path = file.toString();
+ BasicFileAttributes attrs = Files.readAttributes(file,
BasicFileAttributes.class);
+ long mtime = attrs.lastModifiedTime().toMillis();
+ mtimes.put(path, mtime);
+
+ if (prev != null) {
+ var storedMtime = prev.getSourceMtime(path);
+ if (storedMtime.isPresent() && storedMtime.getAsLong() ==
mtime) {
+ String storedHash = prev.getSourceHash(path);
+ if (storedHash != null) {
+ // mtime unchanged — reuse stored hash, skip reading
file content
+ hashes.put(path, storedHash);
+ continue;
+ }
+ }
+ }
+
+ hashes.put(path, Sha256.hash(Files.readAllBytes(file)));
+ }
+ return hashes;
+ }
+
+ // --- External classpath tracking ---
+
+ private Set<String> checkExternalClasspathChanges() {
+ var currentIdentities = computeCurrentJarIdentities();
+ var previousIdentities = previousState.getClasspathIdentities();
+ state.setClasspathIdentities(currentIdentities);
+
+ if (currentIdentities.equals(previousIdentities)) {
+ return Set.of();
+ }
+
+ // Determine which classpath entries changed (added, removed, or
modified)
+ var changedEntries = new TreeSet<String>();
+ for (var entry : currentIdentities.entrySet()) {
+ String prev = previousIdentities.get(entry.getKey());
+ if (prev == null || !prev.equals(entry.getValue())) {
+ changedEntries.add(entry.getKey());
+ }
+ }
+ for (String key : previousIdentities.keySet()) {
+ if (!currentIdentities.containsKey(key)) {
+ changedEntries.add(key);
+ }
+ }
+
+ if (changedEntries.isEmpty()) {
+ return Set.of();
+ }
+
+ // Any external dependency changed — recompile all source files that
reference
+ // types not defined in this module. Since we don't track which
external type
+ // comes from which JAR, we conservatively invalidate all files that
have any
+ // external dependency.
+ var externalTypes = previousState.getExternalDependencies();
+ var invalidated = new TreeSet<String>();
+ for (var entry : previousState.getTypes().entrySet()) {
+ String sf = entry.getValue().sourceFile();
+ if (sf != null) {
+ for (String dep : entry.getValue().classDeps()) {
+ if (externalTypes.contains(dep)) {
+ invalidated.add(sf);
+ break;
+ }
+ }
+ }
+ }
+ return invalidated;
+ }
+
+ private Map<String, String> computeCurrentJarIdentities() {
+ var identities = new LinkedHashMap<String, String>();
+ if (classpathEntries == null) {
+ return identities;
+ }
+ for (Path entry : classpathEntries) {
+ String name = entry.getFileName().toString();
+ if ((name.endsWith(".jar") || name.endsWith(".zip")) &&
Files.exists(entry)) {
+ try {
+ var attrs = Files.readAttributes(entry,
BasicFileAttributes.class);
+ identities.put(
Review Comment:
⚠️ **Medium — JAR identity based on size+mtime, not content hash**
`computeCurrentJarIdentities()` uses `size:lastModifiedTime` as the JAR
identity. This means a JAR that is rebuilt with the exact same size but
different content AND same mtime would not trigger recompilation. More
importantly, it only tracks JARs (`.jar`/`.zip`) but ignores directory
classpath entries entirely.
For reactor module directories on the classpath (added via
`setReactorModulePaths`), there's no change detection at all — if a reactor
sibling module's classes change but the classpath entry is a directory, the
graph strategy won't detect it.
Consider at minimum documenting this limitation, or computing a content
fingerprint for reactor module directories (e.g., walk `.class` files and hash
their mtimes).
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]