gnodet commented on code in PR #1144:
URL: 
https://github.com/apache/maven-compiler-plugin/pull/1144#discussion_r4130001338


##########
src/main/java/org/apache/maven/plugin/compiler/incremental/DependencyScanner.java:
##########
@@ -0,0 +1,197 @@
+/*
+ * 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 javax.lang.model.element.Element;
+import javax.lang.model.element.ExecutableElement;
+import javax.lang.model.element.Modifier;
+import javax.lang.model.element.TypeElement;
+import javax.lang.model.element.VariableElement;
+
+import java.util.Collections;
+import java.util.Set;
+import java.util.TreeSet;
+
+import com.sun.source.tree.BlockTree;
+import com.sun.source.tree.ClassTree;
+import com.sun.source.tree.IdentifierTree;
+import com.sun.source.tree.ImportTree;
+import com.sun.source.tree.MemberSelectTree;
+import com.sun.source.tree.MethodTree;
+import com.sun.source.tree.VariableTree;
+import com.sun.source.util.TreePath;
+import com.sun.source.util.TreePathScanner;
+import com.sun.source.util.Trees;
+
+/**
+ * Walks the AST after ANALYZE to extract type dependencies, classifying each
+ * as signature-level (appears in public API) or implementation-level 
(body-only).
+ */
+public class DependencyScanner extends TreePathScanner<Void, Void> {
+
+    private final Trees trees;
+    private final Set<String> signatureDeps = new TreeSet<>();
+    private final Set<String> implementationDeps = new TreeSet<>();
+    private int bodyDepth = 0;
+    private boolean inPrivateMember = false;
+
+    public DependencyScanner(Trees trees) {
+        this.trees = trees;
+    }
+
+    public Set<String> getSignatureDeps() {
+        return Collections.unmodifiableSet(signatureDeps);
+    }
+
+    public Set<String> getImplementationDeps() {
+        return Collections.unmodifiableSet(implementationDeps);
+    }
+
+    @Override
+    public Void visitImport(ImportTree node, Void p) {
+        // Skip imports — they don't create dependencies, the actual usage 
sites do.
+        // Without this, all imported types appear as signature deps 
regardless of
+        // where they're actually used.
+        return null;
+    }
+
+    @Override
+    public Void visitMethod(MethodTree node, Void p) {
+        Element el = resolveElement(getCurrentPath());
+        boolean wasPrivate = inPrivateMember;
+        if (el != null && el.getModifiers().contains(Modifier.PRIVATE)) {
+            inPrivateMember = true;
+        }
+
+        // Signature parts: return type, parameters, throws, type params, 
annotations
+        scan(node.getModifiers(), p);
+        scan(node.getTypeParameters(), p);
+        scan(node.getReturnType(), p);
+        scan(node.getParameters(), p);
+        scan(node.getThrows(), p);
+        scan(node.getDefaultValue(), p);
+
+        // Body: implementation context
+        if (node.getBody() != null) {
+            bodyDepth++;
+            scan(node.getBody(), p);
+            bodyDepth--;
+        }
+
+        inPrivateMember = wasPrivate;
+        return null;
+    }
+
+    @Override
+    public Void visitVariable(VariableTree node, Void p) {
+        TreePath parentPath = getCurrentPath().getParentPath();
+        if (parentPath != null && parentPath.getLeaf() instanceof ClassTree) {
+            // Field: type is signature (if non-private), initializer is 
implementation
+            Element el = resolveElement(getCurrentPath());
+            boolean wasPrivate = inPrivateMember;
+            if (el != null && el.getModifiers().contains(Modifier.PRIVATE)) {
+                inPrivateMember = true;
+            }
+
+            scan(node.getModifiers(), p);
+            scan(node.getType(), p);
+
+            if (node.getInitializer() != null) {
+                bodyDepth++;
+                scan(node.getInitializer(), p);
+                bodyDepth--;
+            }
+
+            inPrivateMember = wasPrivate;
+            return null;
+        }
+        return super.visitVariable(node, p);
+    }
+
+    @Override
+    public Void visitBlock(BlockTree node, Void p) {
+        // Static/instance initializer blocks (direct children of ClassTree)
+        TreePath parentPath = getCurrentPath().getParentPath();
+        if (parentPath != null && parentPath.getLeaf() instanceof ClassTree) {
+            bodyDepth++;
+            var result = super.visitBlock(node, p);
+            bodyDepth--;
+            return result;
+        }
+        return super.visitBlock(node, p);
+    }
+
+    @Override
+    public Void visitIdentifier(IdentifierTree node, Void p) {
+        recordReference(getCurrentPath());
+        return super.visitIdentifier(node, p);
+    }
+
+    @Override
+    public Void visitMemberSelect(MemberSelectTree node, Void p) {
+        recordReference(getCurrentPath());
+        return super.visitMemberSelect(node, p);
+    }
+
+    private void recordReference(TreePath path) {
+        Element element = resolveElement(path);
+        if (element == null) {
+            return;
+        }
+
+        TypeElement typeElement;
+        if (element instanceof TypeElement te) {
+            typeElement = te;
+        } else if (element instanceof ExecutableElement ee) {
+            typeElement = enclosingType(ee);
+        } else if (element instanceof VariableElement ve) {
+            typeElement = enclosingType(ve);
+        } else {
+            typeElement = null;
+        }
+
+        if (typeElement == null) {
+            return;
+        }
+
+        String qname = typeElement.getQualifiedName().toString();
+        if (qname.isEmpty()) {
+            return;
+        }
+
+        if (bodyDepth > 0 || inPrivateMember) {
+            implementationDeps.add(qname);
+        } else {
+            signatureDeps.add(qname);
+        }
+    }
+
+    private TypeElement enclosingType(Element element) {
+        Element enclosing = element.getEnclosingElement();
+        return enclosing instanceof TypeElement te ? te : null;
+    }
+
+    private Element resolveElement(TreePath path) {
+        try {
+            return trees.getElement(path);
+        } catch (Exception e) {
+            return null;

Review Comment:
   Fixed in cf283d0. Narrowed to `catch(IllegalArgumentException | 
NullPointerException)`.



##########
src/main/java/org/apache/maven/plugin/compiler/ToolExecutor.java:
##########
@@ -915,6 +919,161 @@ 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,
+     * cascading on ABI changes, 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 compileWithAbiIncremental(JavaCompiler compiler, final Options 
configuration, final AbstractCompilerMojo mojo)
+            throws IOException {
+        var abiBuild = new AbiIncrementalBuild(outputDirectory);
+
+        // Collect classpath entries for cross-module tracking
+        var classpathPaths = new ArrayList<Path>();
+        for (var entry : dependencies.entrySet()) {
+            if (entry.getKey() instanceof JavaPathType type) {
+                var location = type.location();
+                if (location.isPresent()
+                        && (location.get() == StandardLocation.CLASS_PATH
+                                || location.get() == 
StandardLocation.MODULE_PATH)) {
+                    classpathPaths.addAll(entry.getValue());
+                }
+            }
+        }
+        abiBuild.setClasspathEntries(classpathPaths);
+
+        // Collect all source file paths
+        var allSourcePaths = new ArrayList<Path>();
+        for (SourceFile sf : sourceFiles) {
+            allSourcePaths.add(sf.file);
+        }
+
+        Set<Path> toCompile = abiBuild.initialize(allSourcePaths);
+        if (toCompile.isEmpty()) {
+            logger.info("Nothing to compile - all classes are up to date (ABI 
strategy).");
+            abiBuild.finish();
+            return;
+        }
+
+        logger.info(
+                abiBuild.isFullBuild()
+                        ? "Compiling " + toCompile.size() + " source file(s) 
(ABI: full build)."
+                        : "Compiling " + toCompile.size() + " source file(s) 
(ABI: incremental).");
+
+        var originalSourceFiles = new ArrayList<>(sourceFiles);
+        boolean success = true;
+
+        while (!toCompile.isEmpty()) {
+            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 = compileWithAbiAnalyzer(compiler, configuration, 
compilerOutput, abiBuild);
+            String output = compilerOutput.toString();
+            if (!output.isBlank()) {
+                logger.warn(output);
+            }
+            if (!success) {
+                break;
+            }
+
+            toCompile = abiBuild.processRound();
+            if (!toCompile.isEmpty()) {
+                logger.info("ABI cascade: recompiling " + toCompile.size() + " 
additional file(s).");
+            }
+        }
+
+        sourceFiles = originalSourceFiles;
+        if (success) {
+            abiBuild.finish();

Review Comment:
   Fixed in cf283d0. Moved `sourceFiles` restore to a `finally` block.



##########
src/main/java/org/apache/maven/plugin/compiler/incremental/AbiIncrementalBuild.java:
##########
@@ -0,0 +1,511 @@
+/*
+ * 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.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeSet;
+
+import com.sun.source.util.JavacTask;
+
+/**
+ * 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 collects analysis data during compilation. Typical usage:
+ *
+ * <pre>{@code
+ * var abi = new AbiIncrementalBuild(outputDir);
+ * abi.setClasspathEntries(classpath);
+ * abi.setReactorModulePaths(reactorModules);
+ *
+ * Set<Path> toCompile = abi.initialize(allSourceFiles);
+ *
+ * while (!toCompile.isEmpty()) {
+ *     JavacTask task = (JavacTask) compiler.getTask(..., toCompile, ...);
+ *     abi.attachTo(task);
+ *     if (!task.call()) break;
+ *     toCompile = abi.processRound();
+ * }
+ *
+ * abi.finish();
+ * }</pre>
+ *
+ * <p>The engine persists its state as {@code .incremental-state} and writes
+ * an {@link AbiManifest} ({@code .abi-fingerprints}) in the output directory
+ * for downstream reactor modules.
+ *
+ * @see CompilationAnalyzer
+ * @see IncrementalState
+ */
+public class AbiIncrementalBuild {
+
+    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 List<Path> allSourceFiles;
+    private Set<String> allCompiled;
+    private CompilationAnalyzer currentAnalyzer;
+    private boolean fullBuild;
+    private int totalSources;
+
+    public AbiIncrementalBuild(Path outputDir) {
+        this.outputDir = outputDir;
+        this.buildDir = outputDir.getParent() != null ? outputDir.getParent() 
: outputDir;
+        this.stateFile = buildDir.resolve(".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/javaci/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);
+    }
+
+    /**
+     * 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<>();
+        sourceHashes = hashSourceFiles(allSourceFiles);
+        previousState = IncrementalState.load(stateFile);
+
+        if (previousState == null) {
+            return initFullBuild(allSourceFiles);
+        } else {
+            return initIncrementalBuild(allSourceFiles);
+        }
+    }
+
+    /**
+     * Attaches the ABI analyzer to a javac task. Must be called before
+     * {@code task.call()} on each compilation round.
+     */
+    public void attachTo(JavacTask task) {
+        currentAnalyzer = new CompilationAnalyzer(task);
+        task.addTaskListener(currentAnalyzer);
+    }
+
+    /**
+     * Processes the results of the last compilation round. Compares new ABI
+     * fingerprints against previous values and determines whether a cascade
+     * round is needed.
+     *
+     * @return the next set of files to compile (cascade consumers), or empty
+     *         if the fixpoint has been reached
+     */
+    public Set<Path> processRound() {
+        if (currentAnalyzer == null) {
+            return Set.of();
+        }
+
+        var results = currentAnalyzer.getResults();
+
+        // Detect ABI changes
+        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 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()) {
+            state.setType(
+                    result.qualifiedName(),
+                    new IncrementalState.TypeInfo(
+                            result.sourceFile(),
+                            result.abiFingerprint(),
+                            result.signatureDeps(),
+                            result.implementationDeps(),
+                            result.annotationTypes()));
+        }
+
+        if (fullBuild || abiChanged.isEmpty()) {
+            return Set.of();
+        }
+
+        // Cascade: find 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;
+    }
+
+    /**
+     * 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());
+        }
+
+        state.save(stateFile);
+        AbiManifest.write(buildDir.resolve(AbiManifest.FILENAME), 
state.getAllAbiFingerprints());
+    }
+
+    /**
+     * 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();
+    }
+
+    // --- 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 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);
+            }
+            state.removeSource(deleted);
+        }
+
+        // Clear stale type entries for files about to be recompiled —
+        // handles cases where a source file previously defined multiple types
+        // but now defines fewer
+        for (String sourceFile : toRecompile) {
+            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 void expandSignatureCascade(String type, IncrementalState state, 
Set<String> result) {
+        for (String consumer : state.getSignatureConsumers(type)) {
+            if (result.add(consumer)) {
+                expandSignatureCascade(consumer, state, result);
+            }
+        }
+    }
+
+    // TODO: inner classes use $ in file names (Foo$Bar.class) but qualified 
names use dots.
+    // javac's ANALYZE fires for top-level types, so inner class names should 
not appear here,
+    // but this needs verification for nested/local class edge cases.

Review Comment:
   Fixed in cf283d0. Now also cleans up inner/nested class files 
(`Foo$Bar.class`, etc.) by listing the directory for matching 
`ClassName$*.class` patterns.



##########
src/main/java/org/apache/maven/plugin/compiler/incremental/DependencyScanner.java:
##########
@@ -0,0 +1,197 @@
+/*
+ * 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 javax.lang.model.element.Element;
+import javax.lang.model.element.ExecutableElement;
+import javax.lang.model.element.Modifier;
+import javax.lang.model.element.TypeElement;
+import javax.lang.model.element.VariableElement;
+
+import java.util.Collections;
+import java.util.Set;
+import java.util.TreeSet;
+
+import com.sun.source.tree.BlockTree;
+import com.sun.source.tree.ClassTree;
+import com.sun.source.tree.IdentifierTree;
+import com.sun.source.tree.ImportTree;
+import com.sun.source.tree.MemberSelectTree;
+import com.sun.source.tree.MethodTree;
+import com.sun.source.tree.VariableTree;
+import com.sun.source.util.TreePath;
+import com.sun.source.util.TreePathScanner;
+import com.sun.source.util.Trees;
+
+/**
+ * Walks the AST after ANALYZE to extract type dependencies, classifying each
+ * as signature-level (appears in public API) or implementation-level 
(body-only).
+ */
+public class DependencyScanner extends TreePathScanner<Void, Void> {
+
+    private final Trees trees;
+    private final Set<String> signatureDeps = new TreeSet<>();
+    private final Set<String> implementationDeps = new TreeSet<>();
+    private int bodyDepth = 0;
+    private boolean inPrivateMember = false;
+
+    public DependencyScanner(Trees trees) {
+        this.trees = trees;
+    }
+
+    public Set<String> getSignatureDeps() {
+        return Collections.unmodifiableSet(signatureDeps);
+    }
+
+    public Set<String> getImplementationDeps() {
+        return Collections.unmodifiableSet(implementationDeps);
+    }
+
+    @Override
+    public Void visitImport(ImportTree node, Void p) {
+        // Skip imports — they don't create dependencies, the actual usage 
sites do.
+        // Without this, all imported types appear as signature deps 
regardless of
+        // where they're actually used.
+        return null;
+    }
+
+    @Override
+    public Void visitMethod(MethodTree node, Void p) {
+        Element el = resolveElement(getCurrentPath());
+        boolean wasPrivate = inPrivateMember;
+        if (el != null && el.getModifiers().contains(Modifier.PRIVATE)) {
+            inPrivateMember = true;
+        }
+
+        // Signature parts: return type, parameters, throws, type params, 
annotations
+        scan(node.getModifiers(), p);
+        scan(node.getTypeParameters(), p);
+        scan(node.getReturnType(), p);
+        scan(node.getParameters(), p);
+        scan(node.getThrows(), p);
+        scan(node.getDefaultValue(), p);
+
+        // Body: implementation context
+        if (node.getBody() != null) {
+            bodyDepth++;
+            scan(node.getBody(), p);
+            bodyDepth--;
+        }
+
+        inPrivateMember = wasPrivate;
+        return null;
+    }
+
+    @Override
+    public Void visitVariable(VariableTree node, Void p) {
+        TreePath parentPath = getCurrentPath().getParentPath();
+        if (parentPath != null && parentPath.getLeaf() instanceof ClassTree) {
+            // Field: type is signature (if non-private), initializer is 
implementation
+            Element el = resolveElement(getCurrentPath());
+            boolean wasPrivate = inPrivateMember;
+            if (el != null && el.getModifiers().contains(Modifier.PRIVATE)) {
+                inPrivateMember = true;
+            }
+
+            scan(node.getModifiers(), p);
+            scan(node.getType(), p);
+
+            if (node.getInitializer() != null) {
+                bodyDepth++;
+                scan(node.getInitializer(), p);
+                bodyDepth--;
+            }
+
+            inPrivateMember = wasPrivate;
+            return null;
+        }
+        return super.visitVariable(node, p);
+    }
+
+    @Override
+    public Void visitBlock(BlockTree node, Void p) {
+        // Static/instance initializer blocks (direct children of ClassTree)
+        TreePath parentPath = getCurrentPath().getParentPath();
+        if (parentPath != null && parentPath.getLeaf() instanceof ClassTree) {
+            bodyDepth++;
+            var result = super.visitBlock(node, p);
+            bodyDepth--;
+            return result;
+        }
+        return super.visitBlock(node, p);
+    }
+
+    @Override
+    public Void visitIdentifier(IdentifierTree node, Void p) {
+        recordReference(getCurrentPath());
+        return super.visitIdentifier(node, p);
+    }
+
+    @Override
+    public Void visitMemberSelect(MemberSelectTree node, Void p) {
+        recordReference(getCurrentPath());
+        return super.visitMemberSelect(node, p);
+    }
+
+    private void recordReference(TreePath path) {
+        Element element = resolveElement(path);
+        if (element == null) {
+            return;
+        }
+
+        TypeElement typeElement;
+        if (element instanceof TypeElement te) {
+            typeElement = te;
+        } else if (element instanceof ExecutableElement ee) {
+            typeElement = enclosingType(ee);
+        } else if (element instanceof VariableElement ve) {
+            typeElement = enclosingType(ve);
+        } else {
+            typeElement = null;
+        }
+
+        if (typeElement == null) {
+            return;
+        }
+
+        String qname = typeElement.getQualifiedName().toString();
+        if (qname.isEmpty()) {
+            return;
+        }
+
+        if (bodyDepth > 0 || inPrivateMember) {
+            implementationDeps.add(qname);
+        } else {
+            signatureDeps.add(qname);
+        }
+    }
+
+    private TypeElement enclosingType(Element element) {
+        Element enclosing = element.getEnclosingElement();
+        return enclosing instanceof TypeElement te ? te : null;
+    }
+
+    private Element resolveElement(TreePath path) {
+        try {
+            return trees.getElement(path);
+        } catch (Exception e) {
+            return null;

Review Comment:
   Fixed in cf283d0e215fd3e4abe7103666a66bfa3ac12e23. Narrowed to `catch 
(IllegalArgumentException | NullPointerException e)` as suggested.



##########
src/main/java/org/apache/maven/plugin/compiler/ToolExecutor.java:
##########
@@ -915,6 +919,161 @@ 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,
+     * cascading on ABI changes, 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 compileWithAbiIncremental(JavaCompiler compiler, final Options 
configuration, final AbstractCompilerMojo mojo)
+            throws IOException {
+        var abiBuild = new AbiIncrementalBuild(outputDirectory);
+
+        // Collect classpath entries for cross-module tracking
+        var classpathPaths = new ArrayList<Path>();
+        for (var entry : dependencies.entrySet()) {
+            if (entry.getKey() instanceof JavaPathType type) {
+                var location = type.location();
+                if (location.isPresent()
+                        && (location.get() == StandardLocation.CLASS_PATH
+                                || location.get() == 
StandardLocation.MODULE_PATH)) {
+                    classpathPaths.addAll(entry.getValue());
+                }
+            }
+        }
+        abiBuild.setClasspathEntries(classpathPaths);
+
+        // Collect all source file paths
+        var allSourcePaths = new ArrayList<Path>();
+        for (SourceFile sf : sourceFiles) {
+            allSourcePaths.add(sf.file);
+        }
+
+        Set<Path> toCompile = abiBuild.initialize(allSourcePaths);
+        if (toCompile.isEmpty()) {
+            logger.info("Nothing to compile - all classes are up to date (ABI 
strategy).");
+            abiBuild.finish();
+            return;
+        }
+
+        logger.info(
+                abiBuild.isFullBuild()
+                        ? "Compiling " + toCompile.size() + " source file(s) 
(ABI: full build)."
+                        : "Compiling " + toCompile.size() + " source file(s) 
(ABI: incremental).");
+
+        var originalSourceFiles = new ArrayList<>(sourceFiles);
+        boolean success = true;
+
+        while (!toCompile.isEmpty()) {
+            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 = compileWithAbiAnalyzer(compiler, configuration, 
compilerOutput, abiBuild);
+            String output = compilerOutput.toString();
+            if (!output.isBlank()) {
+                logger.warn(output);
+            }
+            if (!success) {
+                break;
+            }
+
+            toCompile = abiBuild.processRound();
+            if (!toCompile.isEmpty()) {
+                logger.info("ABI cascade: recompiling " + toCompile.size() + " 
additional file(s).");
+            }
+        }
+
+        sourceFiles = originalSourceFiles;
+        if (success) {
+            abiBuild.finish();

Review Comment:
   Fixed in cf283d0e215fd3e4abe7103666a66bfa3ac12e23. Wrapped the compilation 
loop in try/finally to ensure `sourceFiles` is always restored.



##########
src/main/java/org/apache/maven/plugin/compiler/incremental/ExternalAbiResolver.java:
##########
@@ -0,0 +1,247 @@
+/*
+ * 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.nio.file.FileSystems;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Resolves ABI fingerprints for types defined outside the current compilation
+ * module — in other reactor modules or in external library JARs.
+ *
+ * <p>Resolution uses a three-strategy cascade:
+ * <ol>
+ *   <li><b>Manifest (option 1):</b> If a classpath directory contains an
+ *       {@value AbiManifest#FILENAME} file, fingerprints are read from it.
+ *       This is the fast path for reactor modules compiled with javaci.</li>
+ *   <li><b>Reactor metadata (option 2):</b> The caller can mark specific
+ *       classpath entries as reactor modules via {@code reactorModulePaths}.
+ *       These directories are scanned for class files when no manifest is
+ *       present.</li>
+ *   <li><b>Bytecode fallback (option 3):</b> For any type not resolved above,
+ *       the resolver searches all classpath entries (directories and JARs) and
+ *       computes the ABI fingerprint from bytecode via {@link 
BytecodeAnalyzer}.
+ *       This works with any dependency, including third-party JARs that were
+ *       not built with javaci.</li>
+ * </ol>
+ *
+ * <p>JAR entries are cached by identity (path + size + last-modified-time).
+ * When a JAR has not changed since the last build and a stored fingerprint
+ * exists for the requested type, the stored fingerprint is reused without
+ * opening the JAR.
+ *
+ * @see AbiManifest
+ * @see AbiIncrementalBuild
+ */
+public class ExternalAbiResolver {
+
+    private final List<Path> classpathEntries;
+    private final Set<Path> reactorModulePaths;
+    private Map<Path, Map<String, String>> manifestCache;
+
+    private Map<String, String> previousFingerprints = Map.of();
+    private Set<String> unchangedJars = Set.of();
+
+    public ExternalAbiResolver(List<Path> classpathEntries, Set<Path> 
reactorModulePaths) {
+        this.classpathEntries = classpathEntries != null ? classpathEntries : 
List.of();
+        this.reactorModulePaths = reactorModulePaths != null ? 
reactorModulePaths : Set.of();
+    }
+
+    /**
+     * Configures JAR caching. Fingerprints for types found in unchanged JARs
+     * are reused from the previous build without re-opening the JAR.
+     *
+     * @param previousFingerprints external fingerprints from the previous 
build
+     * @param storedJarIdentities  JAR identities ({@code path -> size:mtime})
+     *                             from the previous build
+     */
+    public void setCachedState(Map<String, String> previousFingerprints, 
Map<String, String> storedJarIdentities) {
+        this.previousFingerprints = previousFingerprints != null ? 
previousFingerprints : Map.of();
+        this.unchangedJars = computeUnchangedJars(storedJarIdentities);
+    }
+
+    /**
+     * Resolves ABI fingerprints for the given set of type names.
+     *
+     * @param typeNames fully qualified type names to resolve
+     * @return map from type name to ABI fingerprint (types not found on the
+     *         classpath are omitted)
+     */
+    public Map<String, String> resolve(Set<String> typeNames) {
+        if (typeNames.isEmpty()) {
+            return Map.of();
+        }
+
+        var result = new HashMap<String, String>();
+        var remaining = new LinkedHashSet<>(typeNames);
+
+        // Strategy 1 & 2: read from manifests in directory classpath entries
+        resolveFromManifests(remaining, result);
+        remaining.removeAll(result.keySet());
+
+        // Strategy 3: compute from bytecode for anything still unresolved
+        if (!remaining.isEmpty()) {
+            resolveFromBytecode(remaining, result);
+        }
+
+        return result;
+    }
+
+    /**
+     * Computes identity strings for all JAR entries on the classpath.
+     * The identity is {@code size:lastModifiedMillis}.
+     */
+    public Map<String, String> computeCurrentJarIdentities() {
+        var identities = new LinkedHashMap<String, String>();
+        for (Path entry : classpathEntries) {
+            if (isJarFile(entry) && Files.exists(entry)) {
+                String id = jarIdentity(entry);
+                if (id != null) {
+                    identities.put(entry.toString(), id);
+                }
+            }
+        }
+        return identities;
+    }
+
+    private void resolveFromManifests(Set<String> typeNames, Map<String, 
String> result) {
+        if (manifestCache == null) {
+            manifestCache = new HashMap<>();
+            for (Path entry : classpathEntries) {
+                if (Files.isDirectory(entry)) {
+                    // Manifest is in the build directory (parent of classes 
dir)
+                    Path parent = entry.getParent();
+                    if (parent != null) {
+                        Path manifestFile = 
parent.resolve(AbiManifest.FILENAME);
+                        Map<String, String> manifest = 
AbiManifest.read(manifestFile);
+                        if (!manifest.isEmpty()) {
+                            manifestCache.put(entry, manifest);
+                        }
+                    }
+                }
+            }
+        }
+
+        for (String typeName : typeNames) {
+            for (var manifest : manifestCache.values()) {
+                String fp = manifest.get(typeName);
+                if (fp != null) {
+                    result.put(typeName, fp);
+                    break;
+                }
+            }
+        }
+    }
+
+    private void resolveFromBytecode(Set<String> typeNames, Map<String, 
String> result) {
+        var remaining = new LinkedHashSet<>(typeNames);
+        for (Path entry : classpathEntries) {
+            if (remaining.isEmpty()) {
+                break;
+            }
+            try {
+                if (Files.isDirectory(entry)) {
+                    resolveFromDirectory(entry, remaining, result);
+                } else if (isJarFile(entry) && Files.exists(entry)) {
+                    resolveFromJar(entry, remaining, result);
+                }
+            } catch (IOException ignored) {
+                // Classpath entry unreadable — skip to next

Review Comment:
   Fixed in cf283d0. Now logs at `FINE` level instead of silently swallowing.



##########
src/main/java/org/apache/maven/plugin/compiler/incremental/AbiIncrementalBuild.java:
##########
@@ -0,0 +1,511 @@
+/*
+ * 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.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeSet;
+
+import com.sun.source.util.JavacTask;
+
+/**
+ * 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 collects analysis data during compilation. Typical usage:
+ *
+ * <pre>{@code
+ * var abi = new AbiIncrementalBuild(outputDir);
+ * abi.setClasspathEntries(classpath);
+ * abi.setReactorModulePaths(reactorModules);
+ *
+ * Set<Path> toCompile = abi.initialize(allSourceFiles);
+ *
+ * while (!toCompile.isEmpty()) {
+ *     JavacTask task = (JavacTask) compiler.getTask(..., toCompile, ...);
+ *     abi.attachTo(task);
+ *     if (!task.call()) break;
+ *     toCompile = abi.processRound();
+ * }
+ *
+ * abi.finish();
+ * }</pre>
+ *
+ * <p>The engine persists its state as {@code .incremental-state} and writes
+ * an {@link AbiManifest} ({@code .abi-fingerprints}) in the output directory
+ * for downstream reactor modules.
+ *
+ * @see CompilationAnalyzer
+ * @see IncrementalState
+ */
+public class AbiIncrementalBuild {
+
+    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 List<Path> allSourceFiles;
+    private Set<String> allCompiled;
+    private CompilationAnalyzer currentAnalyzer;
+    private boolean fullBuild;
+    private int totalSources;
+
+    public AbiIncrementalBuild(Path outputDir) {
+        this.outputDir = outputDir;
+        this.buildDir = outputDir.getParent() != null ? outputDir.getParent() 
: outputDir;
+        this.stateFile = buildDir.resolve(".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/javaci/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);
+    }
+
+    /**
+     * 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<>();
+        sourceHashes = hashSourceFiles(allSourceFiles);
+        previousState = IncrementalState.load(stateFile);
+
+        if (previousState == null) {
+            return initFullBuild(allSourceFiles);
+        } else {
+            return initIncrementalBuild(allSourceFiles);
+        }
+    }
+
+    /**
+     * Attaches the ABI analyzer to a javac task. Must be called before
+     * {@code task.call()} on each compilation round.
+     */
+    public void attachTo(JavacTask task) {
+        currentAnalyzer = new CompilationAnalyzer(task);
+        task.addTaskListener(currentAnalyzer);
+    }
+
+    /**
+     * Processes the results of the last compilation round. Compares new ABI
+     * fingerprints against previous values and determines whether a cascade
+     * round is needed.
+     *
+     * @return the next set of files to compile (cascade consumers), or empty
+     *         if the fixpoint has been reached
+     */
+    public Set<Path> processRound() {
+        if (currentAnalyzer == null) {
+            return Set.of();
+        }
+
+        var results = currentAnalyzer.getResults();
+
+        // Detect ABI changes
+        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 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()) {
+            state.setType(
+                    result.qualifiedName(),
+                    new IncrementalState.TypeInfo(
+                            result.sourceFile(),
+                            result.abiFingerprint(),
+                            result.signatureDeps(),
+                            result.implementationDeps(),
+                            result.annotationTypes()));
+        }
+
+        if (fullBuild || abiChanged.isEmpty()) {
+            return Set.of();
+        }
+
+        // Cascade: find 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;
+    }
+
+    /**
+     * 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());
+        }
+
+        state.save(stateFile);
+        AbiManifest.write(buildDir.resolve(AbiManifest.FILENAME), 
state.getAllAbiFingerprints());
+    }
+
+    /**
+     * 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();
+    }
+
+    // --- 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 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);
+            }
+            state.removeSource(deleted);
+        }
+
+        // Clear stale type entries for files about to be recompiled —
+        // handles cases where a source file previously defined multiple types
+        // but now defines fewer
+        for (String sourceFile : toRecompile) {
+            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 void expandSignatureCascade(String type, IncrementalState state, 
Set<String> result) {
+        for (String consumer : state.getSignatureConsumers(type)) {
+            if (result.add(consumer)) {
+                expandSignatureCascade(consumer, state, result);
+            }
+        }
+    }
+
+    // TODO: inner classes use $ in file names (Foo$Bar.class) but qualified 
names use dots.
+    // javac's ANALYZE fires for top-level types, so inner class names should 
not appear here,
+    // but this needs verification for nested/local class edge cases.

Review Comment:
   Fixed in cf283d0e215fd3e4abe7103666a66bfa3ac12e23. Added cleanup of 
inner/nested class files (`Foo$Bar.class`, `Foo$Bar$Baz.class`, etc.) by 
scanning for files matching the `SimpleClassName$` prefix pattern in the same 
directory.



##########
src/main/java/org/apache/maven/plugin/compiler/incremental/AbiIncrementalBuild.java:
##########
@@ -0,0 +1,511 @@
+/*
+ * 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.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeSet;
+
+import com.sun.source.util.JavacTask;
+
+/**
+ * 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 collects analysis data during compilation. Typical usage:
+ *
+ * <pre>{@code
+ * var abi = new AbiIncrementalBuild(outputDir);
+ * abi.setClasspathEntries(classpath);
+ * abi.setReactorModulePaths(reactorModules);
+ *
+ * Set<Path> toCompile = abi.initialize(allSourceFiles);
+ *
+ * while (!toCompile.isEmpty()) {
+ *     JavacTask task = (JavacTask) compiler.getTask(..., toCompile, ...);
+ *     abi.attachTo(task);
+ *     if (!task.call()) break;
+ *     toCompile = abi.processRound();
+ * }
+ *
+ * abi.finish();
+ * }</pre>
+ *
+ * <p>The engine persists its state as {@code .incremental-state} and writes
+ * an {@link AbiManifest} ({@code .abi-fingerprints}) in the output directory
+ * for downstream reactor modules.
+ *
+ * @see CompilationAnalyzer
+ * @see IncrementalState
+ */
+public class AbiIncrementalBuild {
+
+    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 List<Path> allSourceFiles;
+    private Set<String> allCompiled;
+    private CompilationAnalyzer currentAnalyzer;
+    private boolean fullBuild;
+    private int totalSources;
+
+    public AbiIncrementalBuild(Path outputDir) {
+        this.outputDir = outputDir;
+        this.buildDir = outputDir.getParent() != null ? outputDir.getParent() 
: outputDir;
+        this.stateFile = buildDir.resolve(".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/javaci/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);
+    }
+
+    /**
+     * 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);

Review Comment:
   Good point. An mtime-first optimization would help for large projects. 
Keeping this as a follow-up since the correctness isn't affected and the 
current approach is simpler to reason about for the initial implementation.



##########
src/main/java/org/apache/maven/plugin/compiler/incremental/ExternalAbiResolver.java:
##########
@@ -0,0 +1,247 @@
+/*
+ * 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.nio.file.FileSystems;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Resolves ABI fingerprints for types defined outside the current compilation
+ * module — in other reactor modules or in external library JARs.
+ *
+ * <p>Resolution uses a three-strategy cascade:
+ * <ol>
+ *   <li><b>Manifest (option 1):</b> If a classpath directory contains an
+ *       {@value AbiManifest#FILENAME} file, fingerprints are read from it.
+ *       This is the fast path for reactor modules compiled with javaci.</li>
+ *   <li><b>Reactor metadata (option 2):</b> The caller can mark specific
+ *       classpath entries as reactor modules via {@code reactorModulePaths}.
+ *       These directories are scanned for class files when no manifest is
+ *       present.</li>
+ *   <li><b>Bytecode fallback (option 3):</b> For any type not resolved above,
+ *       the resolver searches all classpath entries (directories and JARs) and
+ *       computes the ABI fingerprint from bytecode via {@link 
BytecodeAnalyzer}.
+ *       This works with any dependency, including third-party JARs that were
+ *       not built with javaci.</li>
+ * </ol>
+ *
+ * <p>JAR entries are cached by identity (path + size + last-modified-time).
+ * When a JAR has not changed since the last build and a stored fingerprint
+ * exists for the requested type, the stored fingerprint is reused without
+ * opening the JAR.
+ *
+ * @see AbiManifest
+ * @see AbiIncrementalBuild
+ */
+public class ExternalAbiResolver {
+
+    private final List<Path> classpathEntries;
+    private final Set<Path> reactorModulePaths;
+    private Map<Path, Map<String, String>> manifestCache;
+
+    private Map<String, String> previousFingerprints = Map.of();
+    private Set<String> unchangedJars = Set.of();
+
+    public ExternalAbiResolver(List<Path> classpathEntries, Set<Path> 
reactorModulePaths) {
+        this.classpathEntries = classpathEntries != null ? classpathEntries : 
List.of();
+        this.reactorModulePaths = reactorModulePaths != null ? 
reactorModulePaths : Set.of();
+    }
+
+    /**
+     * Configures JAR caching. Fingerprints for types found in unchanged JARs
+     * are reused from the previous build without re-opening the JAR.
+     *
+     * @param previousFingerprints external fingerprints from the previous 
build
+     * @param storedJarIdentities  JAR identities ({@code path -> size:mtime})
+     *                             from the previous build
+     */
+    public void setCachedState(Map<String, String> previousFingerprints, 
Map<String, String> storedJarIdentities) {
+        this.previousFingerprints = previousFingerprints != null ? 
previousFingerprints : Map.of();
+        this.unchangedJars = computeUnchangedJars(storedJarIdentities);
+    }
+
+    /**
+     * Resolves ABI fingerprints for the given set of type names.
+     *
+     * @param typeNames fully qualified type names to resolve
+     * @return map from type name to ABI fingerprint (types not found on the
+     *         classpath are omitted)
+     */
+    public Map<String, String> resolve(Set<String> typeNames) {
+        if (typeNames.isEmpty()) {
+            return Map.of();
+        }
+
+        var result = new HashMap<String, String>();
+        var remaining = new LinkedHashSet<>(typeNames);
+
+        // Strategy 1 & 2: read from manifests in directory classpath entries
+        resolveFromManifests(remaining, result);
+        remaining.removeAll(result.keySet());
+
+        // Strategy 3: compute from bytecode for anything still unresolved
+        if (!remaining.isEmpty()) {
+            resolveFromBytecode(remaining, result);
+        }
+
+        return result;
+    }
+
+    /**
+     * Computes identity strings for all JAR entries on the classpath.
+     * The identity is {@code size:lastModifiedMillis}.
+     */
+    public Map<String, String> computeCurrentJarIdentities() {
+        var identities = new LinkedHashMap<String, String>();
+        for (Path entry : classpathEntries) {
+            if (isJarFile(entry) && Files.exists(entry)) {
+                String id = jarIdentity(entry);
+                if (id != null) {
+                    identities.put(entry.toString(), id);
+                }
+            }
+        }
+        return identities;
+    }
+
+    private void resolveFromManifests(Set<String> typeNames, Map<String, 
String> result) {
+        if (manifestCache == null) {
+            manifestCache = new HashMap<>();
+            for (Path entry : classpathEntries) {
+                if (Files.isDirectory(entry)) {
+                    // Manifest is in the build directory (parent of classes 
dir)
+                    Path parent = entry.getParent();
+                    if (parent != null) {
+                        Path manifestFile = 
parent.resolve(AbiManifest.FILENAME);
+                        Map<String, String> manifest = 
AbiManifest.read(manifestFile);
+                        if (!manifest.isEmpty()) {
+                            manifestCache.put(entry, manifest);
+                        }
+                    }
+                }
+            }
+        }
+
+        for (String typeName : typeNames) {
+            for (var manifest : manifestCache.values()) {
+                String fp = manifest.get(typeName);
+                if (fp != null) {
+                    result.put(typeName, fp);
+                    break;
+                }
+            }
+        }
+    }
+
+    private void resolveFromBytecode(Set<String> typeNames, Map<String, 
String> result) {
+        var remaining = new LinkedHashSet<>(typeNames);
+        for (Path entry : classpathEntries) {
+            if (remaining.isEmpty()) {
+                break;
+            }
+            try {
+                if (Files.isDirectory(entry)) {
+                    resolveFromDirectory(entry, remaining, result);
+                } else if (isJarFile(entry) && Files.exists(entry)) {
+                    resolveFromJar(entry, remaining, result);
+                }
+            } catch (IOException ignored) {
+                // Classpath entry unreadable — skip to next

Review Comment:
   Fixed in cf283d0e215fd3e4abe7103666a66bfa3ac12e23. Added 
`LOGGER.log(Level.FINE, ...)` to surface I/O errors at debug level.



##########
src/main/java/org/apache/maven/plugin/compiler/incremental/CompilationAnalyzer.java:
##########
@@ -0,0 +1,122 @@
+/*
+ * 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 javax.lang.model.element.AnnotationMirror;
+import javax.lang.model.element.TypeElement;
+
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.TreeSet;
+
+import com.sun.source.util.JavacTask;
+import com.sun.source.util.TaskEvent;
+import com.sun.source.util.TaskListener;
+import com.sun.source.util.Trees;
+
+/**
+ * A {@link TaskListener} that intercepts javac's {@code ANALYZE} phase to 
extract
+ * per-type dependency and ABI information from a compilation.
+ *
+ * <p>For each type element analyzed, it runs a {@link DependencyScanner} over 
the
+ * compilation unit's AST to collect type references — classified as signature
+ * dependencies (appear in the public API surface) or implementation 
dependencies
+ * (body-only) — and uses {@link AbiExtractor} to compute an ABI fingerprint.
+ * Results are collected into {@link SourceFileAnalysis} records accessible
+ * via {@link #getResults()}.
+ *
+ * <p>JDK-internal types ({@code java.*}, {@code javax.*}, {@code jdk.*},
+ * {@code sun.*}) are filtered from the dependency sets since they never change
+ * across incremental builds.
+ */
+public class CompilationAnalyzer implements TaskListener {
+
+    private final Trees trees;
+    private final Map<String, SourceFileAnalysis> analyses = new 
LinkedHashMap<>();
+
+    public CompilationAnalyzer(JavacTask task) {
+        this.trees = Trees.instance(task);
+    }
+
+    @Override
+    public void finished(TaskEvent e) {
+        if (e.getKind() != TaskEvent.Kind.ANALYZE) {
+            return;
+        }
+
+        TypeElement typeElement = e.getTypeElement();

Review Comment:
   Acknowledged. As noted, this is safe (over-cascading, not under-cascading). 
Optimizing per-type scanning within multi-type CUs is a potential follow-up but 
not a correctness issue.



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

Reply via email to