You are viewing a plain text version of this content. The canonical link for it is here.
Posted to issues@maven.apache.org by "gnodet (via GitHub)" <gi...@apache.org> on 2026/10/04 21:03:52 UTC

[PR] feat: ABI-fingerprint incremental compilation strategy [maven-compiler-plugin]

gnodet opened a new pull request, #1152:
URL: https://github.com/apache/maven-compiler-plugin/pull/1152

   ## Summary
   
   Add an ABI-fingerprint-based incremental compilation strategy as an alternative to the existing timestamp strategy. The new strategy tracks the public API surface of each compiled type and only recompiles consumers whose dependency's ABI actually changed — method body changes do not cascade.
   
   Depends on #1151 (MRJAR infrastructure for bytecode analyzer).
   
   ## Usage
   
   ```xml
   <maven.compiler.incrementalStrategy>abi</maven.compiler.incrementalStrategy>
   ```
   
   Or via CLI: `-Dmaven.compiler.incrementalStrategy=abi`
   
   Default remains `timestamp` (existing behavior unchanged).
   
   ## Core Engine
   
   - **AbiIncrementalBuild**: orchestrates the incremental build cycle — initializes from persisted state, determines what to compile, runs post-compilation bytecode analysis, cascades on ABI changes, persists state
   - **IncrementalState**: binary-serialized persistent state — per-source-file content hashes + mtimes, per-type metadata (ABI fingerprint, signature deps, implementation deps, annotation types, module name), external fingerprints, classpath identities
   - **SourceFileAnalysis**: per-type analysis record produced by bytecode analysis
   - **ExternalAbiResolver**: resolves ABI fingerprints for types from other reactor modules or external JARs (manifest → reactor → bytecode fallback with JAR caching)
   - **AbiManifest**: cross-module fingerprint manifest read/write for downstream reactor modules
   - **ProcessorClassification + ProcessorType**: annotation processor cascade classification (isolating/aggregating/unknown, compatible with Gradle's taxonomy)
   
   ## Mojo Integration
   
   - `AbstractCompilerMojo.incrementalStrategy`: new parameter (values: `timestamp`, `abi`)
   - `ToolExecutor.compileWithAbiIncremental()`: full lifecycle — classpath collection, processor path setup, config hashing, compile loop with ABI cascade
   - `ToolExecutorForTest`: ABI override for test module output
   
   ## Tests
   
   - `AbiIncrementalBuildTest`: full build, incremental, source changes, deletion, cascade, processor classification, config changes, JPMS
   - `IncrementalStateTest`: serialization roundtrip, consumer lookup, source→type indexing
   - `AbiManifestTest`: write/read, empty file, version check
   - `ExternalAbiResolverTest`: manifest resolution, bytecode fallback, JAR caching
   - `ProcessorClassificationTest`: META-INF descriptor parsing, classification logic
   
   ## Integration Tests
   
   - `abi-incremental-basic`: body-only change → only the changed file is recompiled
   - `abi-incremental-cascade`: ABI change (new public method) → cascades to consumers
   
   Part 2 of 3: MRJAR infrastructure (#1151) → **ABI strategy** → ABI refinements.


-- 
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: issues-unsubscribe@maven.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] feat: ABI-fingerprint incremental compilation strategy [maven-compiler-plugin]

Posted by "gnodet-bot (via GitHub)" <gi...@apache.org>.
gnodet-bot commented on code in PR #1152:
URL: https://github.com/apache/maven-compiler-plugin/pull/1152#discussion_r4179335995


##########
src/main/java/org/apache/maven/plugin/compiler/AbstractCompilerMojo.java:
##########
@@ -682,6 +682,34 @@ final Charset charset() {
     @Parameter(property = "maven.compiler.useIncrementalCompilation")
     protected Boolean useIncrementalCompilation;
 
+    /**
+     * The strategy to use for incremental compilation.
+     * <ul>
+     *   <li>{@code timestamp} (default) — the existing timestamp-based strategy from
+     *       {@link IncrementalBuild}. Detects changes by comparing source file modification
+     *       times and triggers full rebuilds when files are added/removed or dependencies change.
+     *       Respects {@link #incrementalCompilation} aspects, {@code staleMillis}, and
+     *       {@code incrementalExcludes}.</li>
+     *   <li>{@code abi} — ABI-fingerprint-based strategy. Tracks the public API surface
+     *       (method signatures, field types, constant values, sealed permits, enum constant

Review Comment:
   ⚠️ **Documentation-vs-code gap:** The Javadoc claims the ABI strategy tracks "sealed permits", but `ClassfileClassAnalyzer.buildCanonical()` does **not** read the `PermittedSubclasses` attribute. Adding or removing a `permits` clause to a sealed class produces a different class file, but the ABI fingerprint would remain unchanged — consumers extending the sealed type won't be recompiled.
   
   Enum constant order is implicitly captured by field ordering (enum constants are `static final` fields), so that claim is fine.
   
   Either:
   1. Add `PermittedSubclasses` handling to `buildCanonical()` and `collectTypes()` in `ClassfileClassAnalyzer`, or
   2. Remove the "sealed permits" claim from the Javadoc.
   
   Option 1 is preferable — a sealed class change is a real ABI change that can break downstream `switch` exhaustiveness.



##########
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 ABI-fingerprint incremental strategy. This method handles the full
+     * lifecycle: determining what to compile, running javac with the analysis TaskListener,

Review Comment:
   📝 **Stale Javadoc:** The doc says "running javac with the analysis TaskListener" but there is no `TaskListener` — the analysis is done post-compilation via `processCompiledClasses()` + `BytecodeAnalyzer`.
   
   ```suggestion
        * Compiles using the ABI-fingerprint incremental strategy. This method handles the full
        * lifecycle: determining what to compile, running javac, performing post-compilation
        * bytecode analysis for ABI cascade, and persisting state.
   ```



##########
src/main/java/org/apache/maven/plugin/compiler/incremental/AbiIncrementalBuild.java:
##########
@@ -0,0 +1,893 @@
+/*
+ * 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.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeSet;
+import java.util.stream.Collectors;
+
+/**
+ * ABI-fingerprint-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
+ * dependency graph and ABI fingerprints. Typical usage:
+ *
+ * {@snippet :
+ * var abi = new AbiIncrementalBuild(outputDir);
+ * abi.setClasspathEntries(classpath);
+ * abi.setReactorModulePaths(reactorModules);
+ * abi.setProcessorPath(processorPath);
+ * abi.setConfigHash(configHash);
+ *
+ * Set<Path> toCompile = abi.initialize(allSourceFiles);
+ *
+ * while (!toCompile.isEmpty()) {
+ *     compiler.compile(toCompile);      // any compiler, any mode
+ *     toCompile = abi.processCompiledClasses(toCompile);
+ * }
+ *
+ * abi.finish();
+ * }
+ *
+ * <p>After each compilation pass, {@link #processCompiledClasses(Set)} scans
+ * the freshly produced {@code .class} files, updates the dependency graph and
+ * ABI fingerprints, and returns any additional files that must be compiled in
+ * the next pass (cascade due to ABI changes, or newly discovered dependencies).
+ * The loop converges in at most 2–3 passes in practice.
+ *
+ * <p>The engine persists its state as {@code .abi-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) and writes an {@link AbiManifest}
+ * ({@code .abi-fingerprints}) in the build directory for downstream reactor modules.
+ *
+ * @see IncrementalState
+ */
+public class AbiIncrementalBuild {
+
+    /** 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;
+    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 java.util.Map<Path, String> outputClassIndex;
+
+    public AbiIncrementalBuild(Path outputDir) {
+        this.outputDir = outputDir;
+        this.buildDir = outputDir.getParent() != null ? outputDir.getParent() : 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(".abi-incremental-state");
+    }
+
+    /**
+     * Sets classpath entries for cross-module ABI tracking. Directory entries
+     * are checked for {@link AbiManifest} files; JAR entries use bytecode
+     * analysis as fallback.
+     */
+    public void setClasspathEntries(List<Path> entries) {
+        this.classpathEntries = entries;
+    }
+
+    /**
+     * Marks specific classpath entries as reactor modules. These are always
+     * checked for ABI changes (via manifest or bytecode).
+     */
+    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 and ABI fingerprints,
+     * detects ABI changes, and returns any additional source files that must be
+     * compiled in the next pass.
+     *
+     * <p>The internal loop handles two cases:
+     * <ul>
+     *   <li><b>ABI cascade</b>: a type's public API changed → its signature consumers
+     *       need recompilation.</li>
+     *   <li><b>New dependency discovery</b>: a source file was compiled and now references
+     *       a type it didn't in the previous build → that dependency is recorded, and if
+     *       the referenced type also changed, the newly discovered consumer is added.</li>
+     * </ul>
+     *
+     * @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 java.util.LinkedHashMap<String, SourceFileAnalysis>();
+        for (Path sourceFile : compiledSourceFiles) {
+            collectClassAnalyses(sourceFile, results);
+        }
+
+        // Detect ABI changes vs. previous state
+        var abiChanged = new TreeSet<String>();
+        for (var result : results.values()) {
+            String prevAbi = previousState != null ? previousState.getAbiFingerprint(result.qualifiedName()) : null;
+            if (prevAbi == null || !prevAbi.equals(result.abiFingerprint())) {
+                abiChanged.add(result.qualifiedName());
+            }
+        }
+
+        // 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() : "";
+            state.setType(
+                    result.qualifiedName(),
+                    new IncrementalState.TypeInfo(
+                            result.sourceFile(),
+                            result.abiFingerprint(),
+                            result.signatureDeps(),
+                            result.implementationDeps(),
+                            result.annotationTypes(),
+                            moduleName));
+        }
+
+        if (fullBuild || abiChanged.isEmpty()) {
+            return Set.of();
+        }
+
+        // Detect module name changes — require a full rebuild
+        if (previousState != null && hasModuleNameChanged(state, previousState)) {
+            return forceFullRebuild();
+        }
+
+        // Cascade: find signature consumers of ABI-changed types
+        var abiCascade = new TreeSet<>(abiChanged);
+        for (String type : abiChanged) {
+            expandSignatureCascade(type, state, abiCascade);
+        }
+
+        var additionalFiles = new TreeSet<Path>();
+        for (String cascadedType : abiCascade) {
+            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, java.util.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 java.util.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) {
+                        // best effort — skip unreadable class files
+                    }
+                });
+            }
+        } 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 {
+                var analysis =
+                        cacheEntry.getValue() != null ? cacheEntry.getValue() : BytecodeAnalyzer.analyze(classFile);
+                String sourceFilePath2 = analysis.isModuleInfo()
+                        ? sourceFilePath
+                        : resolveSourceFile(analysis.className(), sourceFilePath);
+                var sfa = new SourceFileAnalysis(
+                        analysis.className(),
+                        sourceFilePath2,
+                        analysis.signatureTypes(),
+                        analysis.implementationTypes(),
+                        analysis.abiFingerprint(),
+                        analysis.abiCanonical(),
+                        analysis.annotationTypes(),
+                        analysis.moduleName());
+                results.put(analysis.className(), sfa);
+            } catch (IOException e) {
+                // Skip unreadable class files — they'll be caught at compile time
+            }
+        }
+    }
+
+    private java.util.Map<Path, String> getFullBuildClassIndex() throws IOException {
+        if (outputClassIndex == null) {
+            outputClassIndex = new java.util.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: saves state and writes the ABI manifest
+     * for downstream reactor modules.
+     */
+    public void finish() throws IOException {
+        if (state == null) {
+            return;
+        }
+
+        // Resolve and store external fingerprints
+        Set<String> externalDeps = state.getExternalDependencies();
+        if (!externalDeps.isEmpty()) {
+            var resolver = createResolver();
+            state.setExternalFingerprints(resolver.resolve(externalDeps));
+            state.setClasspathIdentities(resolver.computeCurrentJarIdentities());
+        }
+
+        // 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);
+        AbiManifest.write(buildDir.resolve(AbiManifest.FILENAME), state.getAllAbiFingerprints());
+    }
+
+    /**
+     * 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 = IncrementalState.from(sourceHashes, Map.of());
+
+        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 ABI changes
+        Set<String> externallyInvalidated = checkExternalAbiChanges();
+
+        if (changedFiles.isEmpty() && newFiles.isEmpty() && deletedFiles.isEmpty() && externallyInvalidated.isEmpty()) {
+            return Set.of();
+        }
+
+        // Build rebuild cause description
+        var causes = new java.util.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;
+    }
+
+    // --- External ABI tracking ---
+
+    private Set<String> checkExternalAbiChanges() {
+        var invalidated = new TreeSet<String>();
+        Set<String> externalDeps = state.getExternalDependencies();
+        if (externalDeps.isEmpty()) {
+            return invalidated;
+        }
+
+        var resolver = createResolver();
+        Map<String, String> currentFingerprints = resolver.resolve(externalDeps);
+        Map<String, String> storedFingerprints = state.getExternalFingerprints();
+
+        var changedExternalTypes = new TreeSet<String>();
+        for (var entry : currentFingerprints.entrySet()) {
+            String stored = storedFingerprints.get(entry.getKey());
+            if (stored == null || !stored.equals(entry.getValue())) {
+                changedExternalTypes.add(entry.getKey());
+            }
+        }
+
+        if (!changedExternalTypes.isEmpty()) {
+            for (String changedType : changedExternalTypes) {
+                for (String consumer : state.getAllConsumers(changedType)) {
+                    String sf = state.sourceFileFor(consumer);
+                    if (sf != null) {
+                        invalidated.add(sf);
+                    }
+                }
+            }
+        }
+
+        state.setExternalFingerprints(currentFingerprints);
+        state.setClasspathIdentities(resolver.computeCurrentJarIdentities());
+        return invalidated;
+    }
+
+    private ExternalAbiResolver createResolver() {
+        var resolver =
+                new ExternalAbiResolver(classpathEntries != null ? classpathEntries : List.of(), reactorModulePaths);
+        if (previousState != null) {
+            resolver.setCachedState(previousState.getExternalFingerprints(), previousState.getClasspathIdentities());
+        }
+        return resolver;
+    }
+
+    // --- 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 java.util.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();

Review Comment:
   💡 **Mtime-based hash short-circuit — filesystem granularity risk:** On ext3/HFS+/FAT32, mtime granularity can be 1-2 seconds. If a source file is modified twice within that window (common with IDEs that format-on-save, or CI scripts), the second modification has the same mtime, the stale hash is reused, and the change is silently missed.
   
   This is a well-known trade-off (Gradle and Make have the same issue), and the SHA-256 fallback on actual content would catch it on the **next** build when the mtime finally differs. But it means one build can miss a change.
   
   Consider adding a brief note to the Javadoc acknowledging this limitation.



##########
src/main/java/org/apache/maven/plugin/compiler/incremental/AbiIncrementalBuild.java:
##########
@@ -0,0 +1,893 @@
+/*
+ * 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.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeSet;
+import java.util.stream.Collectors;
+
+/**
+ * ABI-fingerprint-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
+ * dependency graph and ABI fingerprints. Typical usage:
+ *
+ * {@snippet :
+ * var abi = new AbiIncrementalBuild(outputDir);
+ * abi.setClasspathEntries(classpath);
+ * abi.setReactorModulePaths(reactorModules);
+ * abi.setProcessorPath(processorPath);
+ * abi.setConfigHash(configHash);
+ *
+ * Set<Path> toCompile = abi.initialize(allSourceFiles);
+ *
+ * while (!toCompile.isEmpty()) {
+ *     compiler.compile(toCompile);      // any compiler, any mode
+ *     toCompile = abi.processCompiledClasses(toCompile);
+ * }
+ *
+ * abi.finish();
+ * }
+ *
+ * <p>After each compilation pass, {@link #processCompiledClasses(Set)} scans
+ * the freshly produced {@code .class} files, updates the dependency graph and
+ * ABI fingerprints, and returns any additional files that must be compiled in
+ * the next pass (cascade due to ABI changes, or newly discovered dependencies).
+ * The loop converges in at most 2–3 passes in practice.
+ *
+ * <p>The engine persists its state as {@code .abi-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) and writes an {@link AbiManifest}
+ * ({@code .abi-fingerprints}) in the build directory for downstream reactor modules.
+ *
+ * @see IncrementalState
+ */
+public class AbiIncrementalBuild {
+
+    /** 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;
+    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 java.util.Map<Path, String> outputClassIndex;
+
+    public AbiIncrementalBuild(Path outputDir) {
+        this.outputDir = outputDir;
+        this.buildDir = outputDir.getParent() != null ? outputDir.getParent() : 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(".abi-incremental-state");
+    }
+
+    /**
+     * Sets classpath entries for cross-module ABI tracking. Directory entries
+     * are checked for {@link AbiManifest} files; JAR entries use bytecode
+     * analysis as fallback.
+     */
+    public void setClasspathEntries(List<Path> entries) {
+        this.classpathEntries = entries;
+    }
+
+    /**
+     * Marks specific classpath entries as reactor modules. These are always
+     * checked for ABI changes (via manifest or bytecode).
+     */
+    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 and ABI fingerprints,
+     * detects ABI changes, and returns any additional source files that must be
+     * compiled in the next pass.
+     *
+     * <p>The internal loop handles two cases:
+     * <ul>
+     *   <li><b>ABI cascade</b>: a type's public API changed → its signature consumers
+     *       need recompilation.</li>
+     *   <li><b>New dependency discovery</b>: a source file was compiled and now references
+     *       a type it didn't in the previous build → that dependency is recorded, and if
+     *       the referenced type also changed, the newly discovered consumer is added.</li>
+     * </ul>
+     *
+     * @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 java.util.LinkedHashMap<String, SourceFileAnalysis>();
+        for (Path sourceFile : compiledSourceFiles) {
+            collectClassAnalyses(sourceFile, results);
+        }
+
+        // Detect ABI changes vs. previous state
+        var abiChanged = new TreeSet<String>();
+        for (var result : results.values()) {
+            String prevAbi = previousState != null ? previousState.getAbiFingerprint(result.qualifiedName()) : null;
+            if (prevAbi == null || !prevAbi.equals(result.abiFingerprint())) {
+                abiChanged.add(result.qualifiedName());
+            }
+        }
+
+        // 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() : "";
+            state.setType(
+                    result.qualifiedName(),
+                    new IncrementalState.TypeInfo(
+                            result.sourceFile(),
+                            result.abiFingerprint(),
+                            result.signatureDeps(),
+                            result.implementationDeps(),
+                            result.annotationTypes(),
+                            moduleName));
+        }
+
+        if (fullBuild || abiChanged.isEmpty()) {
+            return Set.of();
+        }
+
+        // Detect module name changes — require a full rebuild
+        if (previousState != null && hasModuleNameChanged(state, previousState)) {
+            return forceFullRebuild();
+        }
+
+        // Cascade: find signature consumers of ABI-changed types
+        var abiCascade = new TreeSet<>(abiChanged);
+        for (String type : abiChanged) {
+            expandSignatureCascade(type, state, abiCascade);
+        }
+
+        var additionalFiles = new TreeSet<Path>();
+        for (String cascadedType : abiCascade) {
+            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, java.util.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 java.util.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) {
+                        // best effort — skip unreadable class files

Review Comment:
   ⚠️ **Silent failure on class file analysis:** The `catch (IOException e)` inside the `Files.list` lambda silently swallows analysis errors. If `BytecodeAnalyzer.analyze()` throws because a `.class` file is corrupted or truncated (e.g., disk full during compilation), the type is silently dropped from the dependency graph. On the next incremental build, that type's ABI change won't trigger cascade.
   
   At minimum, log at FINE level:
   
   ```suggestion
                       } catch (IOException e) {
                           java.util.logging.Logger.getLogger(AbiIncrementalBuild.class.getName())
                                   .log(java.util.logging.Level.FINE, "Skipping unreadable class file: " + cf, e);
                       }
   ```



##########
src/main/java/org/apache/maven/plugin/compiler/incremental/IncrementalState.java:
##########
@@ -0,0 +1,446 @@
+/*
+ * 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.BufferedInputStream;
+import java.io.BufferedOutputStream;
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.OptionalLong;
+import java.util.Set;
+import java.util.TreeSet;
+
+/**
+ * Persistent state for incremental compilation, storing per-source-file content
+ * hashes and per-type metadata (ABI fingerprint, signature dependencies, and
+ * implementation dependencies).
+ *
+ * <p>Serialized as a compact binary format via {@link java.io.DataOutputStream}
+ * and stored alongside the class output as {@code .incremental-state}. The state
+ * enables the incremental engine to detect which files changed, whether their
+ * ABI is affected, and which consumers need recompilation.
+ *
+ * <p>Consumer lookup methods ({@link #getSignatureConsumers},
+ * {@link #getImplementationConsumers}, {@link #getAllConsumers}) support the
+ * cascade logic: signature consumers are followed transitively (their ABI may
+ * change), while implementation consumers are recompiled directly but do not
+ * cascade further.
+ *
+ * @see AbiIncrementalBuild
+ */
+public class IncrementalState {
+
+    private static final int VERSION = 1;
+
+    private final Map<String, String> sourceHashes = new LinkedHashMap<>();
+    private final Map<String, Long> sourceMtimes = new LinkedHashMap<>();
+    private final Map<String, TypeInfo> types = new LinkedHashMap<>();
+    private final Map<String, String> externalFingerprints = new LinkedHashMap<>();
+    private final Map<String, String> classpathIdentities = new LinkedHashMap<>();
+    private final Map<String, Set<String>> signatureConsumersIndex = new LinkedHashMap<>();
+    private final Map<String, Set<String>> implementationConsumersIndex = new LinkedHashMap<>();
+    private final Map<String, Set<String>> sourceToTypesIndex = new LinkedHashMap<>();
+    private String configHash = "";
+
+    public record TypeInfo(
+            String sourceFile,
+            String abiFingerprint,
+            Set<String> signatureDeps,
+            Set<String> implementationDeps,
+            Set<String> annotationTypes,
+            String moduleName) {}
+
+    public String getSourceHash(String path) {
+        return sourceHashes.get(path);
+    }
+
+    public Map<String, String> getSourceHashes() {
+        return Collections.unmodifiableMap(sourceHashes);
+    }
+
+    public TypeInfo getType(String qualifiedName) {
+        return types.get(qualifiedName);
+    }
+
+    public Map<String, TypeInfo> getTypes() {
+        return Collections.unmodifiableMap(types);
+    }
+
+    public String getAbiFingerprint(String qualifiedName) {
+        TypeInfo info = types.get(qualifiedName);
+        return info != null ? info.abiFingerprint() : null;
+    }
+
+    public void setSourceHash(String path, String hash) {
+        sourceHashes.put(path, hash);
+    }
+
+    /**
+     * Returns the last-modified time (in milliseconds) recorded for the given
+     * source file in the previous build, or {@link OptionalLong#empty()} if not
+     * stored (e.g. first build or state format upgrade).
+     */
+    public OptionalLong getSourceMtime(String path) {
+        Long mtime = sourceMtimes.get(path);
+        return mtime != null ? OptionalLong.of(mtime) : OptionalLong.empty();
+    }
+
+    public void setSourceMtime(String path, long mtime) {
+        sourceMtimes.put(path, mtime);
+    }
+
+    public void setType(String qualifiedName, TypeInfo info) {
+        TypeInfo old = types.put(qualifiedName, info);
+        updateInvertedIndex(qualifiedName, old, info);
+    }
+
+    public void removeSource(String path) {
+        sourceHashes.remove(path);
+        removeTypesForSource(path);
+    }
+
+    /**
+     * Removes all type entries associated with the given source file.
+     * Used to clear stale types before recompilation — a source file
+     * that previously defined types A and B but now only defines A
+     * would otherwise retain phantom type B in the state.
+     */
+    public void removeTypesForSource(String sourceFile) {
+        types.entrySet().removeIf(e -> {
+            if (e.getValue().sourceFile().equals(sourceFile)) {
+                updateInvertedIndex(e.getKey(), e.getValue(), null);
+                return true;
+            }
+            return false;
+        });
+    }

Review Comment:
   ⚠️ **O(n) linear scan per recompiled source:** `removeTypesForSource` uses `removeIf` on the `types` `LinkedHashMap`, scanning ALL types in the module for each source file that needs recompilation. For large modules with thousands of types, this is unnecessarily expensive.
   
   The `sourceToTypesIndex` already has the exact types to remove — use it for O(k) lookup where k is the number of types from that source file:
   
   ```suggestion
       public void removeTypesForSource(String sourceFile) {
           Set<String> typesToRemove = sourceToTypesIndex.remove(sourceFile);
           if (typesToRemove != null) {
               for (String typeName : typesToRemove) {
                   TypeInfo old = types.remove(typeName);
                   if (old != null) {
                       updateInvertedIndex(typeName, old, null);
                   }
               }
           }
       }
   ```



##########
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>

Review Comment:
   💡 **JDK 25 build requirement:** This enforces JDK 25+ to build the plugin. JDK 25 is the current EA release. The `java.lang.classfile` API was finalized in JDK 24 (already GA since March 2025), and the `compile-java24` execution already uses `<release>24</release>`.
   
   Is JDK 25 required for a specific reason beyond `java.lang.classfile`? If not, relaxing to `[24,)` would reduce contributor friction.



-- 
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: issues-unsubscribe@maven.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] feat: ABI-fingerprint incremental compilation strategy [maven-compiler-plugin]

Posted by "gnodet (via GitHub)" <gi...@apache.org>.
gnodet commented on PR #1152:
URL: https://github.com/apache/maven-compiler-plugin/pull/1152#issuecomment-5985151831

   Closing — will be replaced by properly split PRs.


-- 
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: issues-unsubscribe@maven.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] feat: ABI-fingerprint incremental compilation strategy [maven-compiler-plugin]

Posted by "gnodet (via GitHub)" <gi...@apache.org>.
gnodet closed pull request #1152: feat: ABI-fingerprint incremental compilation strategy
URL: https://github.com/apache/maven-compiler-plugin/pull/1152


-- 
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: issues-unsubscribe@maven.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org