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/09/28 23:44:38 UTC

[PR] Add ABI-fingerprint-based incremental compilation strategy [maven-compiler-plugin]

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

   ## Summary
   
   Adds an alternative incremental compilation strategy based on ABI (Application Binary Interface) fingerprinting, alongside the existing timestamp-based `IncrementalBuild`.
   
   When enabled via `-Dmaven.compiler.incrementalStrategy=abi`, the compiler:
   
   - **Extracts per-type dependencies** during javac's ANALYZE phase, classifying each as signature-level (affects ABI consumers) or implementation-level (body-only)
   - **Computes ABI fingerprints** from the public API surface (method signatures, field types, constants, generics, throws clauses)
   - **Detects minimal recompilation sets**: body-only changes compile just the changed file; ABI changes cascade through signature consumers transitively and implementation consumers directly
   - **Tracks cross-module ABI changes** via `.abi-fingerprints` manifests for reactor builds
   - **Caches external dependency fingerprints** by JAR identity (size + mtime) to avoid redundant bytecode analysis
   - **Supports annotation processor classification** (isolating/aggregating/unknown) following the same META-INF convention as Gradle
   
   ### Key differences from the existing timestamp-based approach
   
   | Aspect | Timestamp (current) | ABI (new) |
   |---|---|---|
   | Change detection | File modification time | Content hash (SHA-256) |
   | Recompilation granularity | All-or-nothing on any add/delete | Per-type with dependency cascade |
   | Cross-module | Full rebuild on any dep change | Per-type ABI fingerprint comparison |
   | Dependency tracking | None | Signature vs implementation classification |
   
   ### Configuration
   
   ```xml
   <configuration>
       <incrementalStrategy>abi</incrementalStrategy>
   </configuration>
   ```
   
   Or via property: `mvn compile -Dmaven.compiler.incrementalStrategy=abi`
   
   Default remains `timestamp` (existing behavior, unchanged).
   
   ### New classes
   
   11 classes in `org.apache.maven.plugin.compiler.incremental`:
   
   - `AbiIncrementalBuild` — engine entry point
   - `CompilationAnalyzer` — TaskListener for javac ANALYZE phase
   - `DependencyScanner` — TreePathScanner for dep classification
   - `AbiExtractor` — ABI fingerprint from javax.lang.model
   - `BytecodeAnalyzer` — ASM-based bytecode ABI (cross-module fallback)
   - `IncrementalState` — persistent binary state
   - `AbiManifest` — cross-module fingerprint manifest (versioned format)
   - `ExternalAbiResolver` — classpath scanning with JAR caching
   - `ProcessorClassification` — annotation processor type discovery
   - `ProcessorType` — ISOLATING / AGGREGATING / UNKNOWN enum
   - `SourceFileAnalysis` — per-type analysis record
   
   ### Test plan
   
   - [x] 81 new unit tests across 8 test classes covering all incremental/ code
   - [x] 2 integration tests (body-only change, ABI cascade)
   - [x] All 16 existing tests pass unchanged
   - [ ] Manual testing on a multi-module reactor project
   
   🤖 Generated with [Claude Code](https://claude.com/claude-code)


-- 
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] Add ABI-fingerprint-based incremental compilation strategy [maven-compiler-plugin]

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


##########
pom.xml:
##########
@@ -192,6 +192,60 @@ under the License.
         </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.

Review Comment:
   🔴 **Critical — CI will break on JDK 8 and 21.** The shared workflow (`maven-verify.yml`) uses the default JDK matrix `[ "21", "25", "8" ]` from `apache/maven-gh-actions-shared@v5`, and this project doesn't override `jdk-matrix`. The enforcer requiring `[25,)` will fail the build on JDK 8 and 21. The fail-fast job is `jdk-17-zulu`, which will also be rejected.
   
   Fix: add `jdk-matrix` override to `.github/workflows/maven-verify.yml`:
   
   ```yaml
   jobs:
     build:
       name: Verify
       uses: apache/maven-gh-actions-shared/.github/workflows/maven-verify.yml@v5
       with:
         jdk-matrix: '[ "25" ]'
         jdk-distribution-matrix: '[ "temurin", "zulu", "microsoft" ]'
         maven4-build: true
         maven4-version: '4.0.0-rc-7'
         matrix-exclude: '[
           { "jdk": "25", "distribution": "microsoft"}
         ]'
   ```
   
   Alternatively, if you want the CI to also validate that the compiled JAR works on older JDKs, you could keep the multi-JDK matrix but add `-Denforcer.skip` for non-25 builds — though that's more complex and the enforcer is specifically there to fail-fast on unsupported build JDKs.



-- 
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] Add ABI-fingerprint-based incremental compilation strategy [maven-compiler-plugin]

Posted by "gnodet (via GitHub)" <gi...@apache.org>.
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: issues-unsubscribe@maven.apache.org

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


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

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


##########
src/main/java/org/apache/maven/plugin/compiler/incremental/BytecodeAnalyzer.java:
##########
@@ -0,0 +1,173 @@
+/*
+ * 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.Path;
+import java.util.ArrayList;
+import java.util.Set;
+
+/**
+ * Facade for analyzing compiled {@code .class} files to extract type references
+ * and compute bytecode-level ABI fingerprints.
+ *
+ * <p>This class is part of a multi-release JAR. The root implementation (loaded on
+ * JDK &lt; 24) always returns {@code false} from {@link #isAvailable()} — ABI
+ * fingerprinting is not supported on JDK 17–23. The {@code META-INF/versions/24/}
+ * override (loaded automatically by the JVM on JDK 24+) provides the real
+ * implementation backed by the standard {@code java.lang.classfile} API.
+ *
+ * <p>Callers must check {@link #isAvailable()} before calling {@link #analyze}.
+ * When unavailable, the ABI incremental strategy falls back to the timestamp strategy.
+ *
+ * <p>The {@link ClassAnalysis} record carries the class name, ABI fingerprint,
+ * human-readable canonical form, and two classified sets of type references:
+ * {@link ClassAnalysis#signatureTypes()} (API surface) and
+ * {@link ClassAnalysis#implementationTypes()} (method body instructions only).
+ */
+public final class BytecodeAnalyzer {
+
+    /**
+     * Analysis result for a single {@code .class} file.
+     *
+     * @param className           fully-qualified class name (dot-separated)
+     * @param abiFingerprint      16-character hex SHA-256 prefix of the ABI canonical form
+     * @param abiCanonical        human-readable representation of the public API surface
+     * @param signatureTypes      types appearing in public API surface (method/field descriptors,
+     *                            supertype, interfaces, exception types, annotation types)
+     * @param implementationTypes types appearing only in method body bytecode instructions
+     *                            (INVOKEVIRTUAL, NEW, CHECKCAST, field owners, etc.)
+     * @param annotationTypes     fully-qualified names of annotation types present on the class
+     *                            or its members, used for annotation processor cascade decisions
+     * @param moduleName          Java module name, or empty string if unnamed or non-modular
+     * @param isModuleInfo        {@code true} if this represents {@code module-info.class}
+     * @param sourceFileName      simple source file name from the {@code SourceFile} class file
+     *                            attribute (e.g. {@code "Foo.java"}); empty string if absent
+     */
+    public record ClassAnalysis(
+            String className,
+            String abiFingerprint,
+            String abiCanonical,
+            Set<String> signatureTypes,
+            Set<String> implementationTypes,
+            Set<String> annotationTypes,
+            String moduleName,
+            boolean isModuleInfo,
+            String sourceFileName) {}
+
+    private BytecodeAnalyzer() {}
+
+    /**
+     * Returns {@code true} if ABI fingerprinting is available on the running JVM.
+     *
+     * <p>This method returns {@code false} in the root JAR (JDK &lt; 24). The
+     * {@code META-INF/versions/24/} override returns {@code true}.
+     *
+     * <p>When this returns {@code false}, callers should fall back to the timestamp
+     * incremental strategy and log a warning to the user.
+     *
+     * @return {@code true} if {@link #analyze} can be called safely
+     */
+    public static boolean isAvailable() {
+        return false;
+    }
+
+    /**
+     * Analyzes the class file at {@code classFile}.
+     *
+     * <p>Only call this method after confirming {@link #isAvailable()} returns {@code true}.
+     *
+     * @param classFile path to the {@code .class} file
+     * @return analysis result
+     * @throws IOException if reading the file fails
+     * @throws UnsupportedOperationException if called on JDK &lt; 24
+     */
+    public static ClassAnalysis analyze(Path classFile) throws IOException {
+        throw new UnsupportedOperationException("ABI fingerprinting requires JDK 24 or later (running JDK "
+                + Runtime.version().feature()
+                + "). Check BytecodeAnalyzer.isAvailable() before calling analyze().");
+    }
+
+    /**
+     * Analyzes the given class file bytes.
+     *
+     * <p>Only call this method after confirming {@link #isAvailable()} returns {@code true}.
+     *
+     * @param classBytes raw {@code .class} file content
+     * @return analysis result
+     * @throws UnsupportedOperationException if called on JDK &lt; 24
+     */
+    public static ClassAnalysis analyze(byte[] classBytes) {
+        throw new UnsupportedOperationException("ABI fingerprinting requires JDK 24 or later (running JDK "
+                + Runtime.version().feature()
+                + "). Check BytecodeAnalyzer.isAvailable() before calling analyze().");
+    }
+
+    // --- package-private utilities shared by the analyzer implementations and tests ---
+
+    static String descriptorToReadable(String desc) {

Review Comment:
   Why converting internal names to human-readable names? This is for internal purpose only. Using the internal Java name would be faster (no conversion), shorter (less data to process for computing SHA) and reduce the complexity of the code.



-- 
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] Add ABI-fingerprint-based incremental compilation strategy [maven-compiler-plugin]

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


##########
src/it/abi-incremental-cascade/src/main/java/api/Model.java:
##########
@@ -0,0 +1,33 @@
+/*
+ * 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 api;
+
+public class Model {
+    private String name;
+
+    public String getName() {
+        return name;
+    }
+
+    public void setName(String name) {
+        this.name = name;
+    }
+
+    // ABI_MARKER

Review Comment:
   Removed in 1ba1359. Leftover marker from an early IT iteration used to locate the insertion point for ABI-triggering changes. Cleanup oversight — gone now.



##########
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:
+ *
+ * <pre>{@code

Review Comment:
   Fixed in 1ba1359. `<pre>{@code ... }</pre>` replaced with `{@snippet : ... }`.



-- 
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] Add ABI-fingerprint-based incremental compilation strategy [maven-compiler-plugin]

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


##########
src/main/java/org/apache/maven/plugin/compiler/incremental/AbiIncrementalBuild.java:
##########
@@ -0,0 +1,898 @@
+/*
+ * 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:
+ *
+ * <pre>{@code
+ * 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();
+ * }</pre>
+ *
+ * <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 = "module:";
+
+    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;
+    /** Source files compiled in previous rounds of the current build (for loop detection). */
+    private Set<String> compiledInPreviousRounds;
+    /** 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/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);
+    }
+
+    /**
+     * 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 {
+        if (compiledInPreviousRounds == null) {
+            compiledInPreviousRounds = new TreeSet<>(allCompiled);
+        }
+        // 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());

Review Comment:
   test comment



-- 
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] Add ABI-fingerprint-based incremental compilation strategy [maven-compiler-plugin]

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


##########
src/main/java/org/apache/maven/plugin/compiler/incremental/BytecodeAnalyzer.java:
##########
@@ -0,0 +1,366 @@
+/*
+ * 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.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.MessageDigest;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+import java.util.TreeSet;
+
+import org.objectweb.asm.AnnotationVisitor;
+import org.objectweb.asm.ClassReader;
+import org.objectweb.asm.ClassVisitor;
+import org.objectweb.asm.FieldVisitor;
+import org.objectweb.asm.MethodVisitor;
+import org.objectweb.asm.Opcodes;
+import org.objectweb.asm.Type;
+
+/**
+ * Analyzes compiled {@code .class} files using ASM to extract type references
+ * and compute bytecode-level ABI fingerprints.
+ *
+ * <p>Type references are collected from the class hierarchy, field/method
+ * descriptors, exception types, annotations, and bytecode instructions
+ * ({@code new}, {@code checkcast}, {@code instanceof}, field/method owners).
+ *
+ * <p>The bytecode ABI reflects erased types (no generics) and is computed from
+ * non-private, non-synthetic fields and methods.
+ *
+ * @see AbiExtractor
+ * @see DependencyScanner
+ */
+public class BytecodeAnalyzer {
+
+    public record ClassAnalysis(
+            String className, String abiFingerprint, String abiCanonical, Set<String> referencedTypes) {}
+
+    public static ClassAnalysis analyze(Path classFile) throws IOException {
+        return analyze(Files.readAllBytes(classFile));
+    }
+
+    public static ClassAnalysis analyze(byte[] classBytes) {

Review Comment:
   While Maven Compiler Plugin already depends on ASM at this time, this is something that some would like to remove (see #1148). I would be in favour of removing ASM completely. ABI fingerprints would not be supported for Java below 24, but I think that this is okay. It does not prevent the plugin to work on Java 17, it only reduces the number of strategy available for change detection.
   
   For using classfile API, we could make a multi-release JAR. It is supposed to be easier with the new plugin. My proposal: in a separated pull request (I can volunteer for that), use Compiler Plugin 4.0.0-beta-5 for compiling the Compiler Plugin itself, then configure it as a multi-release project.



-- 
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] Add ABI-fingerprint-based incremental compilation strategy [maven-compiler-plugin]

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


##########
src/main/java/org/apache/maven/plugin/compiler/incremental/AbiIncrementalBuild.java:
##########
@@ -0,0 +1,898 @@
+/*
+ * 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:
+ *
+ * <pre>{@code
+ * 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();
+ * }</pre>
+ *
+ * <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 = "module:";
+
+    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;
+    /** Source files compiled in previous rounds of the current build (for loop detection). */
+    private Set<String> compiledInPreviousRounds;
+    /** Lazily populated on full builds; maps each output class file to its simple top-level class name. */
+    private java.util.Map<Path, String> outputClassIndex;

Review Comment:
   💡 **Dead code:** `compiledInPreviousRounds` (lines 97, 209–210) is initialized in `processCompiledClasses()` but never read anywhere. The loop detection it was presumably intended for is instead handled by the `maxRounds` safety bound in `ToolExecutor`. Either remove the field entirely or wire it into a duplicate-detection check in `processCompiledClasses()`.
   
   If the intent was to detect when the cascade returns files already compiled in a previous round (a stronger invariant than the brute-force `maxRounds` cap), the check would look something like:
   
   ```java
   // After computing additionalFiles
   additionalFiles.removeIf(f -> compiledInPreviousRounds.contains(f.toString()));
   compiledInPreviousRounds.addAll(compiledSourceFiles.stream().map(Path::toString).toList());
   ```
   
   But since `allCompiled` already serves this purpose (line 273: `!allCompiled.contains(sf)`), the field is genuinely redundant.



##########
src/test/java/org/apache/maven/plugin/compiler/incremental/CompilerTestHelper.java:
##########
@@ -0,0 +1,98 @@
+/*
+ * 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.tools.JavaCompiler;
+import javax.tools.StandardLocation;
+import javax.tools.ToolProvider;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Stream;
+
+/**
+ * Shared helper for incremental compilation tests.
+ */
+class CompilerTestHelper {
+
+    static void writeSource(Path sourceDir, String packageName, String className, String source) throws IOException {
+        Path packageDir = sourceDir.resolve(packageName.replace('.', '/'));
+        Files.createDirectories(packageDir);
+        Files.writeString(packageDir.resolve(className + ".java"), source);
+    }
+
+    /**
+     * Compiles all {@code .java} files under {@code sourceDir} into {@code outputDir}
+     * and returns the output directory. No ABI analysis — raw javac only.
+     */
+    static Map<String, SourceFileAnalysis> compileAndAnalyze(Path sourceDir, Path outputDir) throws IOException {
+        Files.createDirectories(outputDir);
+        List<Path> sourceFiles;
+        try (Stream<Path> walk = Files.walk(sourceDir)) {
+            sourceFiles =
+                    walk.filter(p -> p.toString().endsWith(".java")).sorted().toList();
+        }
+
+        JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
+        try (var fm = compiler.getStandardFileManager(null, null, null)) {
+            fm.setLocation(StandardLocation.CLASS_OUTPUT, List.of(outputDir.toFile()));
+            var units = fm.getJavaFileObjectsFromPaths(sourceFiles);
+            var task = compiler.getTask(null, fm, null, null, null, units);
+
+            if (!task.call()) {
+                throw new RuntimeException("Compilation failed");
+            }
+
+            // Build a minimal SourceFileAnalysis map from bytecode (no dep graph needed here)
+            Map<String, SourceFileAnalysis> results = new java.util.LinkedHashMap<>();
+            for (Path sf : sourceFiles) {
+                try (Stream<Path> walk = Files.walk(outputDir)) {
+                    walk.filter(p -> p.toString().endsWith(".class")).forEach(cf -> {
+                        try {
+                            var analysis = BytecodeAnalyzer.analyze(cf);
+                            var sfa = new SourceFileAnalysis(
+                                    analysis.className(),
+                                    sf.toString(),
+                                    analysis.signatureTypes(),
+                                    analysis.implementationTypes(),
+                                    analysis.abiFingerprint(),
+                                    analysis.abiCanonical(),
+                                    analysis.annotationTypes(),
+                                    analysis.moduleName());
+                            results.put(analysis.className(), sfa);
+                        } catch (IOException e) {
+                            // best effort
+                        }
+                    });
+                } catch (IOException e) {
+                    // best effort
+                }
+            }

Review Comment:
   💡 **Misleading source-file attribution:** The outer loop iterates over all source files, and for each one, the inner loop walks **all** `.class` files in the output directory. This means every class file gets analyzed `N` times (once per source file), and each analysis is stored with `sf.toString()` pointing to whichever source file is currently being iterated.
   
   This "works" only because `results.put(analysis.className(), sfa)` uses the class name as key — the last iteration wins, and the source file ends up being the last `sf` in the sorted list. For tests where the actual source attribution doesn't matter (the test only checks fingerprints/deps, not source file paths), this is harmless. But it's accidentally correct — if any test ever checks `sfa.sourceFile()`, it would get the wrong answer.
   
   The fix is to walk class files once and use the `SourceFile` attribute (which `BytecodeAnalyzer` already extracts) to match classes to sources:
   
   ```suggestion
               // Build a minimal SourceFileAnalysis map from bytecode (no dep graph needed here)
               Map<String, SourceFileAnalysis> results = new java.util.LinkedHashMap<>();
               try (Stream<Path> walk = Files.walk(outputDir)) {
                   walk.filter(p -> p.toString().endsWith(".class")).forEach(cf -> {
                       try {
                           var analysis = BytecodeAnalyzer.analyze(cf);
                           // Match to source file by SourceFile attribute
                           String sfName = analysis.sourceFileName(); // e.g. "Model.java"
                           Path matchedSource = sourceFiles.stream()
                                   .filter(sf -> sf.getFileName().toString().equals(sfName))
                                   .findFirst()
                                   .orElse(sourceFiles.get(0));
                           var sfa = new SourceFileAnalysis(
                                   analysis.className(),
                                   matchedSource.toString(),
                                   analysis.signatureTypes(),
                                   analysis.implementationTypes(),
                                   analysis.abiFingerprint(),
                                   analysis.abiCanonical(),
                                   analysis.annotationTypes(),
                                   analysis.moduleName());
                           results.put(analysis.className(), sfa);
                       } catch (IOException e) {
                           // best effort
                       }
                   });
               } catch (IOException e) {
                   // best effort
               }
   ```



-- 
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] Add ABI-fingerprint-based incremental compilation strategy [maven-compiler-plugin]

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


##########
src/main/java24/org/apache/maven/plugin/compiler/incremental/ClassfileClassAnalyzer.java:
##########
@@ -0,0 +1,614 @@
+/*
+ * 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.lang.classfile.Attributes;
+import java.lang.classfile.ClassFile;
+import java.lang.classfile.ClassModel;
+import java.lang.classfile.FieldModel;
+import java.lang.classfile.MethodModel;
+import java.lang.classfile.attribute.ModuleAttribute;
+import java.lang.classfile.attribute.ModuleProvideInfo;
+import java.lang.classfile.attribute.ModuleRequireInfo;
+import java.lang.classfile.instruction.FieldInstruction;
+import java.lang.classfile.instruction.InvokeInstruction;
+import java.lang.classfile.instruction.NewMultiArrayInstruction;
+import java.lang.classfile.instruction.NewObjectInstruction;
+import java.lang.classfile.instruction.TypeCheckInstruction;
+import java.lang.reflect.AccessFlag;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.TreeSet;
+
+/**
+ * {@link ClassAnalyzer} implementation using the standard {@code java.lang.classfile}
+ * API, available since JDK 24.
+ *
+ * <p>This implementation is loaded reflectively by {@link BytecodeAnalyzer} when
+ * the running JVM version is 24 or later. It is compiled separately with
+ * {@code --release 24} to avoid a compile-time dependency on the classfile API
+ * in the main sources.
+ *
+ * <p>Type references are classified into two sets:
+ * <ul>
+ *   <li><b>signatureTypes</b> — types from the public API surface: supertype, interfaces,
+ *       field/method descriptors of non-private members, exception types, annotations.
+ *       These are what downstream consumers structurally depend on.</li>
+ *   <li><b>implementationTypes</b> — types referenced only in method body bytecode
+ *       instructions (INVOKE*, field access, NEW, CHECKCAST, etc.), and descriptor
+ *       types of private members. Changes to these do not cascade to the class's
+ *       signature consumers.</li>
+ * </ul>
+ *
+ * <p>{@code module-info.class} is handled specially: its ABI fingerprint is derived
+ * from the {@code Module} attribute directives (requires, exports, opens, uses,
+ * provides), and no implementation types are collected.
+ *
+ * @see BytecodeAnalyzer
+ * @see ClassAnalyzer
+ */
+class ClassfileClassAnalyzer extends ClassAnalyzer {
+
+    @Override
+    public BytecodeAnalyzer.ClassAnalysis analyze(byte[] classBytes) {
+        ClassModel cm = ClassFile.of().parse(classBytes);
+
+        // module-info.class has the MODULE access flag
+        if (cm.flags().has(AccessFlag.MODULE)) {
+            return analyzeModuleInfo(cm);
+        }
+
+        String className = BytecodeAnalyzer.toJavaName(cm.thisClass().asInternalName());
+        Set<String> signatureTypes = new TreeSet<>();
+        Set<String> implementationTypes = new TreeSet<>();
+        Set<String> annotationTypes = new TreeSet<>();
+
+        collectTypes(cm, signatureTypes, implementationTypes, annotationTypes);
+
+        // Remove self-references and JDK types
+        signatureTypes.remove(className);
+        implementationTypes.remove(className);
+        implementationTypes.removeAll(signatureTypes); // sig takes precedence
+        signatureTypes.removeIf(ClassfileClassAnalyzer::isJdkType);
+        implementationTypes.removeIf(ClassfileClassAnalyzer::isJdkType);
+        annotationTypes.removeIf(ClassfileClassAnalyzer::isJdkType);
+
+        String abiCanonical = buildCanonical(cm);
+        String abiFingerprint = Sha256.hash(abiCanonical);
+
+        // Read the SourceFile attribute for accurate source-file attribution
+        String sourceFileName = cm.findAttribute(Attributes.sourceFile())
+                .map(sf -> sf.sourceFile().stringValue())
+                .orElse("");
+
+        return new BytecodeAnalyzer.ClassAnalysis(
+                className,
+                abiFingerprint,
+                abiCanonical,
+                signatureTypes,
+                implementationTypes,
+                annotationTypes,
+                /* moduleName= */ "",
+                /* isModuleInfo= */ false,
+                sourceFileName);
+    }
+
+    // --- module-info handling ---
+
+    private static BytecodeAnalyzer.ClassAnalysis analyzeModuleInfo(ClassModel cm) {
+        var moduleAttrOpt = cm.findAttribute(Attributes.module());
+        if (moduleAttrOpt.isEmpty()) {
+            return new BytecodeAnalyzer.ClassAnalysis(
+                    "module-info", "", "", Set.of(), Set.of(), Set.of(), "", true, "module-info.java");
+        }
+
+        ModuleAttribute mod = moduleAttrOpt.get();
+        String moduleName = mod.moduleName().name().stringValue();
+        boolean isOpen = cm.flags().has(AccessFlag.OPEN);
+
+        var requires = new TreeSet<String>();
+        var exports = new TreeSet<String>();
+        var opens = new TreeSet<String>();
+        var uses = new TreeSet<String>();
+        var provides = new TreeSet<String>();
+
+        for (ModuleRequireInfo req : mod.requires()) {
+            var sb = new StringBuilder("requires ");
+            if (req.requiresFlags().contains(AccessFlag.TRANSITIVE)) sb.append("transitive ");
+            if (req.requiresFlags().contains(AccessFlag.STATIC_PHASE)) sb.append("static ");
+            sb.append(req.requires().name().stringValue());
+            requires.add(sb.toString());
+        }
+        for (var exp : mod.exports()) {
+            var sb = new StringBuilder("exports ")
+                    .append(exp.exportedPackage().name().stringValue().replace('/', '.'));
+            var tos = exp.exportsTo();
+            if (!tos.isEmpty()) {
+                sb.append(" to ")
+                        .append(tos.stream()
+                                .map(e -> e.name().stringValue())
+                                .sorted()
+                                .reduce((a, b) -> a + ", " + b)
+                                .orElse(""));
+            }
+            exports.add(sb.toString());
+        }
+        for (var op : mod.opens()) {
+            var sb = new StringBuilder("opens ")
+                    .append(op.openedPackage().name().stringValue().replace('/', '.'));
+            var tos = op.opensTo();
+            if (!tos.isEmpty()) {
+                sb.append(" to ")
+                        .append(tos.stream()
+                                .map(e -> e.name().stringValue())
+                                .sorted()
+                                .reduce((a, b) -> a + ", " + b)
+                                .orElse(""));
+            }
+            opens.add(sb.toString());
+        }
+        for (var u : mod.uses()) {
+            uses.add("uses " + BytecodeAnalyzer.toJavaName(u.asInternalName()));
+        }
+        for (ModuleProvideInfo p : mod.provides()) {
+            var sb = new StringBuilder("provides ")
+                    .append(BytecodeAnalyzer.toJavaName(p.provides().asInternalName()));
+            var impls = p.providesWith();
+            if (!impls.isEmpty()) {
+                sb.append(" with ")
+                        .append(impls.stream()
+                                .map(i -> BytecodeAnalyzer.toJavaName(i.asInternalName()))
+                                .sorted()
+                                .reduce((a, b) -> a + ", " + b)
+                                .orElse(""));
+            }
+            provides.add(sb.toString());
+        }
+
+        var canonical = new StringBuilder();
+        if (isOpen) canonical.append("open ");
+        canonical.append("module ").append(moduleName).append('\n');
+        for (String r : requires) canonical.append("  ").append(r).append('\n');
+        for (String e : exports) canonical.append("  ").append(e).append('\n');
+        for (String o : opens) canonical.append("  ").append(o).append('\n');
+        for (String u : uses) canonical.append("  ").append(u).append('\n');
+        for (String p : provides) canonical.append("  ").append(p).append('\n');
+
+        String abiCanonical = canonical.toString();
+        String abiFingerprint = Sha256.hash(abiCanonical);
+
+        // Signature deps for module-info: service types from uses/provides
+        var sigTypes = new TreeSet<String>();
+        for (var u : mod.uses()) {
+            String name = BytecodeAnalyzer.toJavaName(u.asInternalName());
+            if (!isJdkType(name)) sigTypes.add(name);
+        }
+        for (ModuleProvideInfo p : mod.provides()) {
+            String svc = BytecodeAnalyzer.toJavaName(p.provides().asInternalName());
+            if (!isJdkType(svc)) sigTypes.add(svc);
+            for (var impl : p.providesWith()) {
+                String implName = BytecodeAnalyzer.toJavaName(impl.asInternalName());
+                if (!isJdkType(implName)) sigTypes.add(implName);
+            }
+        }
+
+        // module-info qualified name uses the MODULE_PREFIX convention
+        String qualifiedName = AbiIncrementalBuild.MODULE_PREFIX + moduleName;
+
+        return new BytecodeAnalyzer.ClassAnalysis(
+                qualifiedName,
+                abiFingerprint,
+                abiCanonical,
+                sigTypes,
+                Set.of(),
+                Set.of(),
+                moduleName,
+                /* isModuleInfo= */ true,
+                /* sourceFileName= */ "module-info.java");
+    }
+
+    // --- type reference collection ---
+
+    private static void collectTypes(
+            ClassModel cm, Set<String> signatureTypes, Set<String> implementationTypes, Set<String> annotationTypes) {
+
+        // Superclass and interfaces are always signature-level
+        cm.superclass().ifPresent(sup -> addRef(sup.asInternalName(), signatureTypes));
+        for (var iface : cm.interfaces()) {
+            addRef(iface.asInternalName(), signatureTypes);
+        }
+
+        // Class generic signature (e.g. "class Foo<T extends Bar>") — extract type args
+        cm.findAttribute(Attributes.signature())
+                .ifPresent(sig -> addGenericSignatureRefs(sig.signature().stringValue(), signatureTypes));
+
+        // Class-level annotations → signature + annotation tracking
+        collectAnnotations(
+                cm.findAttribute(Attributes.runtimeVisibleAnnotations())
+                        .map(a -> a.annotations())
+                        .orElse(List.of()),
+                signatureTypes,
+                annotationTypes);
+        collectAnnotations(
+                cm.findAttribute(Attributes.runtimeInvisibleAnnotations())
+                        .map(a -> a.annotations())
+                        .orElse(List.of()),
+                signatureTypes,
+                annotationTypes);
+
+        for (FieldModel field : cm.fields()) {
+            boolean isPrivate = field.flags().has(AccessFlag.PRIVATE);
+            Set<String> descTarget = isPrivate ? implementationTypes : signatureTypes;
+
+            addDescriptor(field.fieldType().stringValue(), descTarget);
+
+            // Field generic signature — extract concrete type arguments (e.g. Foo in List<Foo>)
+            field.findAttribute(Attributes.signature())
+                    .ifPresent(sig -> addGenericSignatureRefs(sig.signature().stringValue(), descTarget));
+
+            // Field annotations go to the same target as the field descriptor
+            collectAnnotations(
+                    field.findAttribute(Attributes.runtimeVisibleAnnotations())
+                            .map(a -> a.annotations())
+                            .orElse(List.of()),
+                    descTarget,
+                    annotationTypes);
+            collectAnnotations(
+                    field.findAttribute(Attributes.runtimeInvisibleAnnotations())
+                            .map(a -> a.annotations())
+                            .orElse(List.of()),
+                    descTarget,
+                    annotationTypes);
+        }
+
+        for (MethodModel method : cm.methods()) {
+            boolean isPrivate = method.flags().has(AccessFlag.PRIVATE);
+            Set<String> sigTarget = isPrivate ? implementationTypes : signatureTypes;
+
+            // Method descriptor types (params + return) and exceptions → sig if non-private
+            addMethodDescriptor(method.methodType().stringValue(), sigTarget);
+            method.findAttribute(Attributes.exceptions())
+                    .ifPresent(ex -> ex.exceptions().forEach(e -> addRef(e.asInternalName(), sigTarget)));
+
+            // Method generic signature — extract concrete type arguments (e.g. Foo in List<Foo>)
+            method.findAttribute(Attributes.signature())
+                    .ifPresent(sig -> addGenericSignatureRefs(sig.signature().stringValue(), sigTarget));
+
+            // Method annotations → same target as descriptor
+            collectAnnotations(
+                    method.findAttribute(Attributes.runtimeVisibleAnnotations())
+                            .map(a -> a.annotations())
+                            .orElse(List.of()),
+                    sigTarget,
+                    annotationTypes);
+            collectAnnotations(
+                    method.findAttribute(Attributes.runtimeInvisibleAnnotations())
+                            .map(a -> a.annotations())
+                            .orElse(List.of()),
+                    sigTarget,
+                    annotationTypes);
+
+            // Method body instructions → always implementation-level
+            method.code().ifPresent(code -> {
+                for (var element : code) {
+                    switch (element) {
+                        case InvokeInstruction ii -> addRef(ii.owner().asInternalName(), implementationTypes);
+                        case FieldInstruction fi -> addRef(fi.owner().asInternalName(), implementationTypes);
+                        case TypeCheckInstruction tci -> addRef(tci.type().asInternalName(), implementationTypes);
+                        case NewObjectInstruction noi -> addRef(noi.className().asInternalName(), implementationTypes);
+                        case NewMultiArrayInstruction nma ->
+                            addRef(nma.arrayType().asInternalName(), implementationTypes);
+                        default -> {}
+                    }
+                }
+            });
+        }
+
+        // Scan the constant pool for class references that are not captured by instructions.
+        // This covers compile-time constants (static final primitives) whose values are inlined
+        // by javac — the referencing class has no GETSTATIC instruction, but the resolved class
+        // is still present in the constant pool.
+        for (var entry : cm.constantPool()) {
+            if (entry instanceof java.lang.classfile.constantpool.ClassEntry ce) {
+                String internalName = ce.asInternalName();
+                // Skip array type descriptors and the class itself
+                if (!internalName.startsWith("[")
+                        && !internalName.equals(cm.thisClass().asInternalName())) {
+                    addRef(internalName, implementationTypes);
+                }
+            }
+        }
+    }
+
+    private static void collectAnnotations(
+            java.util.List<? extends java.lang.classfile.Annotation> annotations,
+            Set<String> typeTarget,
+            Set<String> annotationTarget) {
+        for (var ann : annotations) {
+            String descriptor = ann.className().stringValue();
+            String name = descriptorToJavaName(descriptor);
+            if (name != null) {
+                typeTarget.add(name);
+                annotationTarget.add(name);
+            }
+        }
+    }
+
+    // --- ABI canonical form ---
+
+    private static String buildCanonical(ClassModel cm) {
+        var fields = new ArrayList<FieldInfo>();
+        var methods = new ArrayList<MethodInfo>();
+
+        for (FieldModel field : cm.fields()) {
+            int access = accessMask(field.flags().flags());
+            if (!isPrivateOrSynthetic(access)) {
+                Object constantValue = field.findAttribute(Attributes.constantValue())
+                        .map(cv -> cv.constant().constantValue())
+                        .orElse(null);
+                String fieldSig = field.findAttribute(Attributes.signature())
+                        .map(s -> s.signature().stringValue())
+                        .orElse(null);
+                fields.add(new FieldInfo(
+                        access,
+                        field.fieldName().stringValue(),
+                        field.fieldType().stringValue(),
+                        constantValue,
+                        fieldSig));
+            }
+        }
+
+        for (MethodModel method : cm.methods()) {
+            int access = accessMask(method.flags().flags());
+            String name = method.methodName().stringValue();
+            if (!isPrivateOrSynthetic(access) && !"<clinit>".equals(name)) {
+                String methodSig = method.findAttribute(Attributes.signature())
+                        .map(s -> s.signature().stringValue())
+                        .orElse(null);
+                methods.add(new MethodInfo(access, name, method.methodType().stringValue(), methodSig));
+            }
+        }
+
+        int classAccess = accessMask(cm.flags().flags());
+        String className = BytecodeAnalyzer.toJavaName(cm.thisClass().asInternalName());
+        String classSignature = cm.findAttribute(Attributes.signature())
+                .map(s -> s.signature().stringValue())
+                .orElse(null);
+        String superName = cm.superclass()
+                .map(sup -> BytecodeAnalyzer.toJavaName(sup.asInternalName()))
+                .orElse(null);
+        List<String> ifaceNames = cm.interfaces().stream()
+                .map(iface -> BytecodeAnalyzer.toJavaName(iface.asInternalName()))
+                .toList();
+
+        return buildCanonicalForm(classAccess, className, classSignature, superName, ifaceNames, fields, methods);
+    }
+
+    // --- generic signature type reference extraction ---
+
+    /**
+     * Extracts all concrete class type references from a JVM generic signature string
+     * (JVMS §4.7.9.1) and adds them to {@code types}.
+     *
+     * <p>Examples (signature → extracted types):
+     * <ul>
+     *   <li>{@code Ljava/util/List<Lcom/example/Foo;>;} → {@code com.example.Foo}
+     *   <li>{@code Ljava/util/Map<Lcom/example/Key;Lcom/example/Val;>;} → {@code com.example.Key}, {@code com.example.Val}
+     *   <li>{@code (Lcom/example/Req;)Lcom/example/Resp;} → {@code com.example.Req}, {@code com.example.Resp}
+     *   <li>{@code TT;} (type variable) → nothing
+     *   <li>{@code +Lcom/Foo;} (wildcard) → {@code com.Foo}
+     * </ul>
+     *
+     * <p>Type variables ({@code TName;}) and primitive types are intentionally skipped —
+     * they are not concrete dependencies.
+     */
+    private static void addGenericSignatureRefs(String sig, Set<String> types) {
+        if (sig == null || sig.isEmpty()) {
+            return;
+        }
+        int[] pos = {0};
+        // A ClassSignature or MethodSignature may start with FormalTypeParameters: <T:Lbound;>...
+        // Detect this case and parse the FormalTypeParameters block with the dedicated mode.
+        if (sig.charAt(0) == '<') {
+            pos[0]++; // consume '<'
+            parseSig(sig, pos, types, true); // FormalTypeParameter mode
+            if (pos[0] < sig.length() && sig.charAt(pos[0]) == '>') {
+                pos[0]++; // consume '>'
+            }
+        }
+        // Parse the remainder (SuperclassSignature, SuperinterfaceSignatures, or method sig body)
+        parseSig(sig, pos, types, false);
+    }
+
+    /**
+     * Recursive descent parser for JVM generic signatures (JVMS §4.7.9.1).
+     * {@code inFormalTypeParams} must be {@code true} when called from inside a
+     * {@code FormalTypeParameters} block ({@code <...>} at the top of a class or method
+     * signature), where the grammar is {@code Identifier ClassBound {InterfaceBound}} rather
+     * than {@code TypeArgument*}.  All other call sites pass {@code false}.
+     */
+    private static void parseSig(String sig, int[] pos, Set<String> types, boolean inFormalTypeParams) {
+        while (pos[0] < sig.length()) {
+            char c = sig.charAt(pos[0]);
+            if (inFormalTypeParams) {
+                // Inside FormalTypeParameters: Identifier ClassBound {InterfaceBound}
+                // Identifier is an arbitrary Java identifier (NOT prefixed by 'T')
+                // followed by ':' (ClassBound) or ':' (InterfaceBound)
+                if (c == '>') {
+                    // End of FormalTypeParameters block — stop, let the caller consume '>'
+                    return;
+                }
+                // Skip the Identifier (type parameter name, e.g. "T", "E", "Type")
+                while (pos[0] < sig.length() && sig.charAt(pos[0]) != ':' && sig.charAt(pos[0]) != '>') {
+                    pos[0]++;
+                }
+                // Parse ClassBound and InterfaceBound(s): each is ':' followed by a FieldTypeSignature
+                while (pos[0] < sig.length() && sig.charAt(pos[0]) == ':') {
+                    pos[0]++; // consume ':'
+                    // ClassBound may be empty (just ':' with no FieldTypeSignature before next ':' or '>')
+                    if (pos[0] < sig.length()) {
+                        char next = sig.charAt(pos[0]);
+                        if (next == 'L' || next == '[' || next == 'T') {
+                            // There is a FieldTypeSignature — parse it as a TypeArgument (NOT inFormalTypeParams)
+                            parseSig(sig, pos, types, false);

Review Comment:
   🔴 **Bug — `parseSig(false)` overconsumes past the first bound, breaking multi-parameter signatures.**
   
   When called from within FTP mode to parse a single `FieldTypeSignature`, `parseSig(false)` doesn't stop after one type — it loops until `pos >= sig.length()`. After consuming the bound (e.g. `Lfoo/Foo;`), the next char is either `:` (next bound), `>` (end of FTP), or another type param identifier — all of which hit the `default → pos++` case and get consumed in non-FTP mode.
   
   This re-introduces the original `T`-confusion bug for any type parameter after the first:
   ```
   <K:Lfoo/Foo;T:Lbar/Bar;>  →  foo.Foo collected ✓, bar.Bar missed ❌
   ```
   
   Fix: instead of delegating to `parseSig(false)` (which overconsumes), parse exactly one `FieldTypeSignature` inline:
   
   ```suggestion
                           if (next == 'L') {
                               parseClassTypeSignature(sig, pos, types);
                           } else if (next == '[') {
                               // ArrayTypeSignature — consume '[' prefixes then the element type
                               while (pos[0] < sig.length() && sig.charAt(pos[0]) == '[') pos[0]++;
                               if (pos[0] < sig.length() && sig.charAt(pos[0]) == 'L') {
                                   parseClassTypeSignature(sig, pos, types);
                               } else if (pos[0] < sig.length() && sig.charAt(pos[0]) == 'T') {
                                   // Array of type variable — skip T Identifier ;
                                   pos[0]++;
                                   while (pos[0] < sig.length() && sig.charAt(pos[0]) != ';') pos[0]++;
                                   if (pos[0] < sig.length()) pos[0]++;
                               } else if (pos[0] < sig.length()) {
                                   pos[0]++; // primitive array element
                               }
                           } else if (next == 'T') {
                               // TypeVariableSignature as bound (e.g. <E:TT;>) — skip
                               pos[0]++;
                               while (pos[0] < sig.length() && sig.charAt(pos[0]) != ';') pos[0]++;
                               if (pos[0] < sig.length()) pos[0]++;
                           }
   ```
   
   This ensures each bound consumes exactly its own `FieldTypeSignature` and returns control to the FTP `while ':'` loop, which then correctly handles the next type parameter's bounds.
   
   Add a test case:
   ```java
   // Multi-param: second param named 'T' with a non-JDK bound
   CompilerTestHelper.writeSource(sourceDir, "test", "BiContainer", """
           package test;
           public class BiContainer<K, T extends MyBound> {
               public K key() { return null; }
               public T val() { return null; }
           }
           """);
   var biAnalysis = BytecodeAnalyzer.analyze(outputDir.resolve("test/BiContainer.class"));
   assertTrue(biAnalysis.signatureTypes().contains("test.MyBound"),
           "MyBound as second type param bound should be tracked; got: " + biAnalysis.signatureTypes());
   ```



-- 
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] Add ABI-fingerprint-based incremental compilation strategy [maven-compiler-plugin]

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


##########
src/it/abi-incremental-cascade/src/main/java/api/Model.java:
##########
@@ -0,0 +1,33 @@
+/*
+ * 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 api;
+
+public class Model {
+    private String name;
+
+    public String getName() {
+        return name;
+    }
+
+    public void setName(String name) {
+        this.name = name;
+    }
+
+    // ABI_MARKER

Review Comment:
   What is this comment for exactly?



-- 
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] Add ABI-fingerprint-based incremental compilation strategy [maven-compiler-plugin]

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


##########
src/test/java/org/apache/maven/plugin/compiler/incremental/BytecodeAnalyzerTest.java:
##########
@@ -0,0 +1,303 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.plugin.compiler.incremental;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class BytecodeAnalyzerTest {
+
+    @TempDir
+    Path sourceDir;
+
+    @TempDir
+    Path outputDir;
+
+    @Test
+    void descriptorToReadableVoid() {
+        assertEquals("void", BytecodeAnalyzer.descriptorToReadable("V"));
+    }
+
+    @Test
+    void descriptorToReadableBoolean() {
+        assertEquals("boolean", BytecodeAnalyzer.descriptorToReadable("Z"));
+    }
+
+    @Test
+    void descriptorToReadableByte() {
+        assertEquals("byte", BytecodeAnalyzer.descriptorToReadable("B"));
+    }
+
+    @Test
+    void descriptorToReadableChar() {
+        assertEquals("char", BytecodeAnalyzer.descriptorToReadable("C"));
+    }
+
+    @Test
+    void descriptorToReadableShort() {
+        assertEquals("short", BytecodeAnalyzer.descriptorToReadable("S"));
+    }
+
+    @Test
+    void descriptorToReadableInt() {
+        assertEquals("int", BytecodeAnalyzer.descriptorToReadable("I"));
+    }
+
+    @Test
+    void descriptorToReadableLong() {
+        assertEquals("long", BytecodeAnalyzer.descriptorToReadable("J"));
+    }
+
+    @Test
+    void descriptorToReadableFloat() {
+        assertEquals("float", BytecodeAnalyzer.descriptorToReadable("F"));
+    }
+
+    @Test
+    void descriptorToReadableDouble() {
+        assertEquals("double", BytecodeAnalyzer.descriptorToReadable("D"));
+    }
+
+    @Test
+    void descriptorToReadableObject() {
+        assertEquals("java.lang.String", BytecodeAnalyzer.descriptorToReadable("Ljava/lang/String;"));
+    }
+
+    @Test
+    void descriptorToReadableArray() {
+        assertEquals("int[]", BytecodeAnalyzer.descriptorToReadable("[I"));
+        assertEquals("java.lang.String[]", BytecodeAnalyzer.descriptorToReadable("[Ljava/lang/String;"));
+    }
+
+    @Test
+    void descriptorToReadableEmpty() {
+        assertEquals("", BytecodeAnalyzer.descriptorToReadable(""));
+    }
+
+    @Test
+    void parseParamsMultiple() {
+        assertEquals("java.lang.String, int", BytecodeAnalyzer.parseParams("(Ljava/lang/String;I)V"));
+    }
+
+    @Test
+    void parseParamsEmpty() {
+        assertEquals("", BytecodeAnalyzer.parseParams("()V"));
+    }
+
+    @Test
+    void parseReturnType() {
+        assertEquals("java.lang.String", BytecodeAnalyzer.parseReturn("(I)Ljava/lang/String;"));
+        assertEquals("void", BytecodeAnalyzer.parseReturn("()V"));
+        assertEquals("int", BytecodeAnalyzer.parseReturn("()I"));
+    }
+
+    @Test
+    void analyzePathProducesCorrectClassName() throws Exception {
+        CompilerTestHelper.writeSource(
+                sourceDir,
+                "test",
+                "Hello",
+                "package test; public class Hello { public String greet() { return \"hi\"; } }");
+        CompilerTestHelper.compileAndAnalyze(sourceDir, outputDir);
+
+        Path classFile = outputDir.resolve("test/Hello.class");
+        assertTrue(Files.exists(classFile));
+
+        var analysis = BytecodeAnalyzer.analyze(classFile);
+        assertEquals("test.Hello", analysis.className());
+        assertNotNull(analysis.abiFingerprint());
+        assertNotNull(analysis.abiCanonical());
+    }
+
+    @Test
+    void analyzeBytesEquivalentToAnalyzePath() throws Exception {
+        CompilerTestHelper.writeSource(
+                sourceDir, "test", "Hello", "package test; public class Hello { public int value() { return 42; } }");
+        CompilerTestHelper.compileAndAnalyze(sourceDir, outputDir);
+
+        Path classFile = outputDir.resolve("test/Hello.class");
+        var fromPath = BytecodeAnalyzer.analyze(classFile);
+        var fromBytes = BytecodeAnalyzer.analyze(Files.readAllBytes(classFile));
+
+        assertEquals(fromPath.className(), fromBytes.className());
+        assertEquals(fromPath.abiFingerprint(), fromBytes.abiFingerprint());
+        assertEquals(fromPath.abiCanonical(), fromBytes.abiCanonical());
+        assertEquals(fromPath.signatureTypes(), fromBytes.signatureTypes());
+        assertEquals(fromPath.implementationTypes(), fromBytes.implementationTypes());
+    }
+
+    @Test
+    void genericTypeChangeAffectsFingerprint() throws Exception {
+        // Compile with List<String>
+        CompilerTestHelper.writeSource(sourceDir, "test", "Generics", """
+                package test;
+                public class Generics {
+                    public java.util.List<String> getNames() { return null; }
+                    public java.util.Map<String, Integer> getMap() { return null; }
+                }
+                """);
+        CompilerTestHelper.compileAndAnalyze(sourceDir, outputDir);
+        var analysis1 = BytecodeAnalyzer.analyze(outputDir.resolve("test/Generics.class"));
+
+        // Recompile with List<Integer> — erased descriptor is identical,
+        // but generic signature differs
+        Path srcFile = sourceDir.resolve("test/Generics.java");
+        Files.writeString(srcFile, """
+                package test;
+                public class Generics {
+                    public java.util.List<Integer> getNames() { return null; }
+                    public java.util.Map<String, Integer> getMap() { return null; }
+                }
+                """);
+        CompilerTestHelper.compileAndAnalyze(sourceDir, outputDir);
+        var analysis2 = BytecodeAnalyzer.analyze(outputDir.resolve("test/Generics.class"));
+
+        assertNotEquals(
+                analysis1.abiFingerprint(),
+                analysis2.abiFingerprint(),
+                "Changing List<String> to List<Integer> should change bytecode ABI fingerprint");
+        assertTrue(analysis2.abiCanonical().contains("<sig:"), "Canonical form should include generic signatures");
+    }
+
+    @Test
+    void classLevelGenericSignatureAffectsFingerprint() throws Exception {
+        CompilerTestHelper.writeSource(sourceDir, "test", "Box", """
+                package test;
+                public class Box<T> {
+                    public T get() { return null; }
+                }
+                """);
+        CompilerTestHelper.compileAndAnalyze(sourceDir, outputDir);
+        var analysis1 = BytecodeAnalyzer.analyze(outputDir.resolve("test/Box.class"));
+
+        Files.writeString(sourceDir.resolve("test/Box.java"), """
+                package test;
+                public class Box<T extends Comparable<T>> {
+                    public T get() { return null; }
+                }
+                """);
+        CompilerTestHelper.compileAndAnalyze(sourceDir, outputDir);
+        var analysis2 = BytecodeAnalyzer.analyze(outputDir.resolve("test/Box.class"));
+
+        assertNotEquals(
+                analysis1.abiFingerprint(),
+                analysis2.abiFingerprint(),
+                "Changing type parameter bounds should change bytecode ABI fingerprint");
+    }
+
+    @Test
+    @org.junit.jupiter.api.condition.EnabledForJreRange(min = org.junit.jupiter.api.condition.JRE.JAVA_24)
+    void genericTypeArgumentsTrackedAsDependencies() throws Exception {
+        // Foo is a user-defined type used only as a generic type argument
+        CompilerTestHelper.writeSource(sourceDir, "test", "Foo", "package test; public class Foo {}");
+        CompilerTestHelper.writeSource(sourceDir, "test", "Bar", "package test; public class Bar {}");
+        CompilerTestHelper.writeSource(sourceDir, "test", "Subject", """
+                package test;
+                import java.util.List;
+                import java.util.Map;
+                import java.util.function.Function;
+                public class Subject {
+                    // Foo appears only as a type argument — not in the erased descriptor
+                    public List<Foo> getItems() { return null; }
+                    // Bar appears as a Map value type argument
+                    public Map<String, Bar> getMap() { return null; }
+                    // Wildcards: Foo as lower bound
+                    public List<? extends Foo> getBounded() { return null; }
+                    // Foo in field generic signature
+                    public java.util.Optional<Foo> optFoo = java.util.Optional.empty();
+                }
+                """);
+        CompilerTestHelper.compileAndAnalyze(sourceDir, outputDir);
+
+        var analysis = BytecodeAnalyzer.analyze(outputDir.resolve("test/Subject.class"));
+
+        assertTrue(
+                analysis.signatureTypes().contains("test.Foo"),
+                "test.Foo used as generic type arg should be in signatureTypes; got: " + analysis.signatureTypes());
+        assertTrue(
+                analysis.signatureTypes().contains("test.Bar"),
+                "test.Bar used as generic type arg should be in signatureTypes; got: " + analysis.signatureTypes());
+    }
+
+    @Test
+    @org.junit.jupiter.api.condition.EnabledForJreRange(min = org.junit.jupiter.api.condition.JRE.JAVA_24)
+    void genericSuperclassTypeArgsTracked() throws Exception {
+        CompilerTestHelper.writeSource(sourceDir, "test", "Item", "package test; public class Item {}");
+        CompilerTestHelper.writeSource(sourceDir, "test", "ItemList", """
+                package test;
+                import java.util.AbstractList;
+                // Item appears only in the class generic signature (extends AbstractList<Item>)
+                public class ItemList extends AbstractList<Item> {
+                    @Override public Item get(int i) { return null; }
+                    @Override public int size() { return 0; }
+                }
+                """);
+        CompilerTestHelper.compileAndAnalyze(sourceDir, outputDir);
+
+        var analysis = BytecodeAnalyzer.analyze(outputDir.resolve("test/ItemList.class"));
+
+        assertTrue(
+                analysis.signatureTypes().contains("test.Item"),
+                "test.Item as superclass type arg should be in signatureTypes; got: " + analysis.signatureTypes());
+    }
+

Review Comment:
   Fixed in e474b58. Added `formalTypeParameterBoundsTracked()` and `multipleTypeParameterBoundsAllTracked()` tests to `BytecodeAnalyzerTest`, both confirming bounds like `<T extends MyBound>` appear in `signatureTypes`.



##########
src/main/java24/org/apache/maven/plugin/compiler/incremental/ClassfileClassAnalyzer.java:
##########
@@ -0,0 +1,614 @@
+/*
+ * 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.lang.classfile.Attributes;
+import java.lang.classfile.ClassFile;
+import java.lang.classfile.ClassModel;
+import java.lang.classfile.FieldModel;
+import java.lang.classfile.MethodModel;
+import java.lang.classfile.attribute.ModuleAttribute;
+import java.lang.classfile.attribute.ModuleProvideInfo;
+import java.lang.classfile.attribute.ModuleRequireInfo;
+import java.lang.classfile.instruction.FieldInstruction;
+import java.lang.classfile.instruction.InvokeInstruction;
+import java.lang.classfile.instruction.NewMultiArrayInstruction;
+import java.lang.classfile.instruction.NewObjectInstruction;
+import java.lang.classfile.instruction.TypeCheckInstruction;
+import java.lang.reflect.AccessFlag;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.TreeSet;
+
+/**
+ * {@link ClassAnalyzer} implementation using the standard {@code java.lang.classfile}
+ * API, available since JDK 24.
+ *
+ * <p>This implementation is loaded reflectively by {@link BytecodeAnalyzer} when
+ * the running JVM version is 24 or later. It is compiled separately with
+ * {@code --release 24} to avoid a compile-time dependency on the classfile API
+ * in the main sources.
+ *
+ * <p>Type references are classified into two sets:
+ * <ul>
+ *   <li><b>signatureTypes</b> — types from the public API surface: supertype, interfaces,
+ *       field/method descriptors of non-private members, exception types, annotations.
+ *       These are what downstream consumers structurally depend on.</li>
+ *   <li><b>implementationTypes</b> — types referenced only in method body bytecode
+ *       instructions (INVOKE*, field access, NEW, CHECKCAST, etc.), and descriptor
+ *       types of private members. Changes to these do not cascade to the class's
+ *       signature consumers.</li>
+ * </ul>
+ *
+ * <p>{@code module-info.class} is handled specially: its ABI fingerprint is derived
+ * from the {@code Module} attribute directives (requires, exports, opens, uses,
+ * provides), and no implementation types are collected.
+ *
+ * @see BytecodeAnalyzer
+ * @see ClassAnalyzer
+ */
+class ClassfileClassAnalyzer extends ClassAnalyzer {
+
+    @Override
+    public BytecodeAnalyzer.ClassAnalysis analyze(byte[] classBytes) {
+        ClassModel cm = ClassFile.of().parse(classBytes);
+
+        // module-info.class has the MODULE access flag
+        if (cm.flags().has(AccessFlag.MODULE)) {
+            return analyzeModuleInfo(cm);
+        }
+
+        String className = BytecodeAnalyzer.toJavaName(cm.thisClass().asInternalName());
+        Set<String> signatureTypes = new TreeSet<>();
+        Set<String> implementationTypes = new TreeSet<>();
+        Set<String> annotationTypes = new TreeSet<>();
+
+        collectTypes(cm, signatureTypes, implementationTypes, annotationTypes);
+
+        // Remove self-references and JDK types
+        signatureTypes.remove(className);
+        implementationTypes.remove(className);
+        implementationTypes.removeAll(signatureTypes); // sig takes precedence
+        signatureTypes.removeIf(ClassfileClassAnalyzer::isJdkType);
+        implementationTypes.removeIf(ClassfileClassAnalyzer::isJdkType);
+        annotationTypes.removeIf(ClassfileClassAnalyzer::isJdkType);
+
+        String abiCanonical = buildCanonical(cm);
+        String abiFingerprint = Sha256.hash(abiCanonical);
+
+        // Read the SourceFile attribute for accurate source-file attribution
+        String sourceFileName = cm.findAttribute(Attributes.sourceFile())
+                .map(sf -> sf.sourceFile().stringValue())
+                .orElse("");
+
+        return new BytecodeAnalyzer.ClassAnalysis(
+                className,
+                abiFingerprint,
+                abiCanonical,
+                signatureTypes,
+                implementationTypes,
+                annotationTypes,
+                /* moduleName= */ "",
+                /* isModuleInfo= */ false,
+                sourceFileName);
+    }
+
+    // --- module-info handling ---
+
+    private static BytecodeAnalyzer.ClassAnalysis analyzeModuleInfo(ClassModel cm) {
+        var moduleAttrOpt = cm.findAttribute(Attributes.module());
+        if (moduleAttrOpt.isEmpty()) {
+            return new BytecodeAnalyzer.ClassAnalysis(
+                    "module-info", "", "", Set.of(), Set.of(), Set.of(), "", true, "module-info.java");
+        }
+
+        ModuleAttribute mod = moduleAttrOpt.get();
+        String moduleName = mod.moduleName().name().stringValue();
+        boolean isOpen = cm.flags().has(AccessFlag.OPEN);
+
+        var requires = new TreeSet<String>();
+        var exports = new TreeSet<String>();
+        var opens = new TreeSet<String>();
+        var uses = new TreeSet<String>();
+        var provides = new TreeSet<String>();
+
+        for (ModuleRequireInfo req : mod.requires()) {
+            var sb = new StringBuilder("requires ");
+            if (req.requiresFlags().contains(AccessFlag.TRANSITIVE)) sb.append("transitive ");
+            if (req.requiresFlags().contains(AccessFlag.STATIC_PHASE)) sb.append("static ");
+            sb.append(req.requires().name().stringValue());
+            requires.add(sb.toString());
+        }
+        for (var exp : mod.exports()) {
+            var sb = new StringBuilder("exports ")
+                    .append(exp.exportedPackage().name().stringValue().replace('/', '.'));
+            var tos = exp.exportsTo();
+            if (!tos.isEmpty()) {
+                sb.append(" to ")
+                        .append(tos.stream()
+                                .map(e -> e.name().stringValue())
+                                .sorted()
+                                .reduce((a, b) -> a + ", " + b)
+                                .orElse(""));
+            }
+            exports.add(sb.toString());
+        }
+        for (var op : mod.opens()) {
+            var sb = new StringBuilder("opens ")
+                    .append(op.openedPackage().name().stringValue().replace('/', '.'));
+            var tos = op.opensTo();
+            if (!tos.isEmpty()) {
+                sb.append(" to ")
+                        .append(tos.stream()
+                                .map(e -> e.name().stringValue())
+                                .sorted()
+                                .reduce((a, b) -> a + ", " + b)
+                                .orElse(""));
+            }
+            opens.add(sb.toString());
+        }
+        for (var u : mod.uses()) {
+            uses.add("uses " + BytecodeAnalyzer.toJavaName(u.asInternalName()));
+        }
+        for (ModuleProvideInfo p : mod.provides()) {
+            var sb = new StringBuilder("provides ")
+                    .append(BytecodeAnalyzer.toJavaName(p.provides().asInternalName()));
+            var impls = p.providesWith();
+            if (!impls.isEmpty()) {
+                sb.append(" with ")
+                        .append(impls.stream()
+                                .map(i -> BytecodeAnalyzer.toJavaName(i.asInternalName()))
+                                .sorted()
+                                .reduce((a, b) -> a + ", " + b)
+                                .orElse(""));
+            }
+            provides.add(sb.toString());
+        }
+
+        var canonical = new StringBuilder();
+        if (isOpen) canonical.append("open ");
+        canonical.append("module ").append(moduleName).append('\n');
+        for (String r : requires) canonical.append("  ").append(r).append('\n');
+        for (String e : exports) canonical.append("  ").append(e).append('\n');
+        for (String o : opens) canonical.append("  ").append(o).append('\n');
+        for (String u : uses) canonical.append("  ").append(u).append('\n');
+        for (String p : provides) canonical.append("  ").append(p).append('\n');
+
+        String abiCanonical = canonical.toString();
+        String abiFingerprint = Sha256.hash(abiCanonical);
+
+        // Signature deps for module-info: service types from uses/provides
+        var sigTypes = new TreeSet<String>();
+        for (var u : mod.uses()) {
+            String name = BytecodeAnalyzer.toJavaName(u.asInternalName());
+            if (!isJdkType(name)) sigTypes.add(name);
+        }
+        for (ModuleProvideInfo p : mod.provides()) {
+            String svc = BytecodeAnalyzer.toJavaName(p.provides().asInternalName());
+            if (!isJdkType(svc)) sigTypes.add(svc);
+            for (var impl : p.providesWith()) {
+                String implName = BytecodeAnalyzer.toJavaName(impl.asInternalName());
+                if (!isJdkType(implName)) sigTypes.add(implName);
+            }
+        }
+
+        // module-info qualified name uses the MODULE_PREFIX convention
+        String qualifiedName = AbiIncrementalBuild.MODULE_PREFIX + moduleName;
+
+        return new BytecodeAnalyzer.ClassAnalysis(
+                qualifiedName,
+                abiFingerprint,
+                abiCanonical,
+                sigTypes,
+                Set.of(),
+                Set.of(),
+                moduleName,
+                /* isModuleInfo= */ true,
+                /* sourceFileName= */ "module-info.java");
+    }
+
+    // --- type reference collection ---
+
+    private static void collectTypes(
+            ClassModel cm, Set<String> signatureTypes, Set<String> implementationTypes, Set<String> annotationTypes) {
+
+        // Superclass and interfaces are always signature-level
+        cm.superclass().ifPresent(sup -> addRef(sup.asInternalName(), signatureTypes));
+        for (var iface : cm.interfaces()) {
+            addRef(iface.asInternalName(), signatureTypes);
+        }
+
+        // Class generic signature (e.g. "class Foo<T extends Bar>") — extract type args
+        cm.findAttribute(Attributes.signature())
+                .ifPresent(sig -> addGenericSignatureRefs(sig.signature().stringValue(), signatureTypes));
+
+        // Class-level annotations → signature + annotation tracking
+        collectAnnotations(
+                cm.findAttribute(Attributes.runtimeVisibleAnnotations())
+                        .map(a -> a.annotations())
+                        .orElse(List.of()),
+                signatureTypes,
+                annotationTypes);
+        collectAnnotations(
+                cm.findAttribute(Attributes.runtimeInvisibleAnnotations())
+                        .map(a -> a.annotations())
+                        .orElse(List.of()),
+                signatureTypes,
+                annotationTypes);
+
+        for (FieldModel field : cm.fields()) {
+            boolean isPrivate = field.flags().has(AccessFlag.PRIVATE);
+            Set<String> descTarget = isPrivate ? implementationTypes : signatureTypes;
+
+            addDescriptor(field.fieldType().stringValue(), descTarget);
+
+            // Field generic signature — extract concrete type arguments (e.g. Foo in List<Foo>)
+            field.findAttribute(Attributes.signature())
+                    .ifPresent(sig -> addGenericSignatureRefs(sig.signature().stringValue(), descTarget));
+
+            // Field annotations go to the same target as the field descriptor
+            collectAnnotations(
+                    field.findAttribute(Attributes.runtimeVisibleAnnotations())
+                            .map(a -> a.annotations())
+                            .orElse(List.of()),
+                    descTarget,
+                    annotationTypes);
+            collectAnnotations(
+                    field.findAttribute(Attributes.runtimeInvisibleAnnotations())
+                            .map(a -> a.annotations())
+                            .orElse(List.of()),
+                    descTarget,
+                    annotationTypes);
+        }
+
+        for (MethodModel method : cm.methods()) {
+            boolean isPrivate = method.flags().has(AccessFlag.PRIVATE);
+            Set<String> sigTarget = isPrivate ? implementationTypes : signatureTypes;
+
+            // Method descriptor types (params + return) and exceptions → sig if non-private
+            addMethodDescriptor(method.methodType().stringValue(), sigTarget);
+            method.findAttribute(Attributes.exceptions())
+                    .ifPresent(ex -> ex.exceptions().forEach(e -> addRef(e.asInternalName(), sigTarget)));
+
+            // Method generic signature — extract concrete type arguments (e.g. Foo in List<Foo>)
+            method.findAttribute(Attributes.signature())
+                    .ifPresent(sig -> addGenericSignatureRefs(sig.signature().stringValue(), sigTarget));
+
+            // Method annotations → same target as descriptor
+            collectAnnotations(
+                    method.findAttribute(Attributes.runtimeVisibleAnnotations())
+                            .map(a -> a.annotations())
+                            .orElse(List.of()),
+                    sigTarget,
+                    annotationTypes);
+            collectAnnotations(
+                    method.findAttribute(Attributes.runtimeInvisibleAnnotations())
+                            .map(a -> a.annotations())
+                            .orElse(List.of()),
+                    sigTarget,
+                    annotationTypes);
+
+            // Method body instructions → always implementation-level
+            method.code().ifPresent(code -> {
+                for (var element : code) {
+                    switch (element) {
+                        case InvokeInstruction ii -> addRef(ii.owner().asInternalName(), implementationTypes);
+                        case FieldInstruction fi -> addRef(fi.owner().asInternalName(), implementationTypes);
+                        case TypeCheckInstruction tci -> addRef(tci.type().asInternalName(), implementationTypes);
+                        case NewObjectInstruction noi -> addRef(noi.className().asInternalName(), implementationTypes);
+                        case NewMultiArrayInstruction nma ->
+                            addRef(nma.arrayType().asInternalName(), implementationTypes);
+                        default -> {}
+                    }
+                }
+            });
+        }
+
+        // Scan the constant pool for class references that are not captured by instructions.
+        // This covers compile-time constants (static final primitives) whose values are inlined
+        // by javac — the referencing class has no GETSTATIC instruction, but the resolved class
+        // is still present in the constant pool.
+        for (var entry : cm.constantPool()) {
+            if (entry instanceof java.lang.classfile.constantpool.ClassEntry ce) {
+                String internalName = ce.asInternalName();
+                // Skip array type descriptors and the class itself
+                if (!internalName.startsWith("[")
+                        && !internalName.equals(cm.thisClass().asInternalName())) {
+                    addRef(internalName, implementationTypes);
+                }
+            }
+        }
+    }
+
+    private static void collectAnnotations(
+            java.util.List<? extends java.lang.classfile.Annotation> annotations,
+            Set<String> typeTarget,
+            Set<String> annotationTarget) {
+        for (var ann : annotations) {
+            String descriptor = ann.className().stringValue();
+            String name = descriptorToJavaName(descriptor);
+            if (name != null) {
+                typeTarget.add(name);
+                annotationTarget.add(name);
+            }
+        }
+    }
+
+    // --- ABI canonical form ---
+
+    private static String buildCanonical(ClassModel cm) {
+        var fields = new ArrayList<FieldInfo>();
+        var methods = new ArrayList<MethodInfo>();
+
+        for (FieldModel field : cm.fields()) {
+            int access = accessMask(field.flags().flags());
+            if (!isPrivateOrSynthetic(access)) {
+                Object constantValue = field.findAttribute(Attributes.constantValue())
+                        .map(cv -> cv.constant().constantValue())
+                        .orElse(null);
+                String fieldSig = field.findAttribute(Attributes.signature())
+                        .map(s -> s.signature().stringValue())
+                        .orElse(null);
+                fields.add(new FieldInfo(
+                        access,
+                        field.fieldName().stringValue(),
+                        field.fieldType().stringValue(),
+                        constantValue,
+                        fieldSig));
+            }
+        }
+
+        for (MethodModel method : cm.methods()) {
+            int access = accessMask(method.flags().flags());
+            String name = method.methodName().stringValue();
+            if (!isPrivateOrSynthetic(access) && !"<clinit>".equals(name)) {
+                String methodSig = method.findAttribute(Attributes.signature())
+                        .map(s -> s.signature().stringValue())
+                        .orElse(null);
+                methods.add(new MethodInfo(access, name, method.methodType().stringValue(), methodSig));
+            }
+        }
+
+        int classAccess = accessMask(cm.flags().flags());
+        String className = BytecodeAnalyzer.toJavaName(cm.thisClass().asInternalName());
+        String classSignature = cm.findAttribute(Attributes.signature())
+                .map(s -> s.signature().stringValue())
+                .orElse(null);
+        String superName = cm.superclass()
+                .map(sup -> BytecodeAnalyzer.toJavaName(sup.asInternalName()))
+                .orElse(null);
+        List<String> ifaceNames = cm.interfaces().stream()
+                .map(iface -> BytecodeAnalyzer.toJavaName(iface.asInternalName()))
+                .toList();
+
+        return buildCanonicalForm(classAccess, className, classSignature, superName, ifaceNames, fields, methods);
+    }
+
+    // --- generic signature type reference extraction ---
+
+    /**
+     * Extracts all concrete class type references from a JVM generic signature string
+     * (JVMS §4.7.9.1) and adds them to {@code types}.
+     *
+     * <p>Examples (signature → extracted types):
+     * <ul>
+     *   <li>{@code Ljava/util/List<Lcom/example/Foo;>;} → {@code com.example.Foo}
+     *   <li>{@code Ljava/util/Map<Lcom/example/Key;Lcom/example/Val;>;} → {@code com.example.Key}, {@code com.example.Val}
+     *   <li>{@code (Lcom/example/Req;)Lcom/example/Resp;} → {@code com.example.Req}, {@code com.example.Resp}
+     *   <li>{@code TT;} (type variable) → nothing
+     *   <li>{@code +Lcom/Foo;} (wildcard) → {@code com.Foo}
+     * </ul>
+     *
+     * <p>Type variables ({@code TName;}) and primitive types are intentionally skipped —
+     * they are not concrete dependencies.
+     */
+    private static void addGenericSignatureRefs(String sig, Set<String> types) {
+        if (sig == null || sig.isEmpty()) {
+            return;
+        }
+        int[] pos = {0};
+        // A ClassSignature or MethodSignature may start with FormalTypeParameters: <T:Lbound;>...
+        // Detect this case and parse the FormalTypeParameters block with the dedicated mode.
+        if (sig.charAt(0) == '<') {
+            pos[0]++; // consume '<'
+            parseSig(sig, pos, types, true); // FormalTypeParameter mode
+            if (pos[0] < sig.length() && sig.charAt(pos[0]) == '>') {
+                pos[0]++; // consume '>'
+            }
+        }
+        // Parse the remainder (SuperclassSignature, SuperinterfaceSignatures, or method sig body)
+        parseSig(sig, pos, types, false);
+    }
+
+    /**
+     * Recursive descent parser for JVM generic signatures (JVMS §4.7.9.1).
+     * {@code inFormalTypeParams} must be {@code true} when called from inside a
+     * {@code FormalTypeParameters} block ({@code <...>} at the top of a class or method
+     * signature), where the grammar is {@code Identifier ClassBound {InterfaceBound}} rather
+     * than {@code TypeArgument*}.  All other call sites pass {@code false}.
+     */
+    private static void parseSig(String sig, int[] pos, Set<String> types, boolean inFormalTypeParams) {
+        while (pos[0] < sig.length()) {
+            char c = sig.charAt(pos[0]);
+            if (inFormalTypeParams) {
+                // Inside FormalTypeParameters: Identifier ClassBound {InterfaceBound}
+                // Identifier is an arbitrary Java identifier (NOT prefixed by 'T')
+                // followed by ':' (ClassBound) or ':' (InterfaceBound)
+                if (c == '>') {
+                    // End of FormalTypeParameters block — stop, let the caller consume '>'
+                    return;
+                }
+                // Skip the Identifier (type parameter name, e.g. "T", "E", "Type")
+                while (pos[0] < sig.length() && sig.charAt(pos[0]) != ':' && sig.charAt(pos[0]) != '>') {
+                    pos[0]++;
+                }
+                // Parse ClassBound and InterfaceBound(s): each is ':' followed by a FieldTypeSignature
+                while (pos[0] < sig.length() && sig.charAt(pos[0]) == ':') {
+                    pos[0]++; // consume ':'
+                    // ClassBound may be empty (just ':' with no FieldTypeSignature before next ':' or '>')
+                    if (pos[0] < sig.length()) {
+                        char next = sig.charAt(pos[0]);
+                        if (next == 'L' || next == '[' || next == 'T') {
+                            // There is a FieldTypeSignature — parse it as a TypeArgument (NOT inFormalTypeParams)
+                            parseSig(sig, pos, types, false);

Review Comment:
   Fixed in e474b58. The FTP bound parser no longer calls `parseSig(sig, pos, types, false)` for each bound (which looped to end-of-string). It now dispatches directly to `parseClassTypeSignature` for `L`, handles `[` and `T` inline — consuming exactly one `FieldTypeSignature` per bound.



##########
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 system property
+              so it is resolved before target/classes/ by the bootstrap class loader.
+            -->
+            <classesDirectory>${project.build.outputDirectory}/META-INF/versions/24</classesDirectory>
+            <additionalClasspathElements>

Review Comment:
   Fixed in 1ba1359. Comment now reads: "We prepend META-INF/versions/24/ via the classesDirectory parameter".



##########
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,
+     * 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>();
+        var reactorPaths = new LinkedHashSet<Path>();
+        for (var entry : dependencies.entrySet()) {
+            if (entry.getKey() instanceof JavaPathType type) {
+                var location = type.location();
+                if (location.isPresent()
+                        && (location.get() == StandardLocation.CLASS_PATH
+                                || location.get() == StandardLocation.MODULE_PATH)) {
+                    classpathPaths.addAll(entry.getValue());
+                    if (location.get() == StandardLocation.MODULE_PATH) {
+                        for (Path p : entry.getValue()) {
+                            if (Files.isDirectory(p)) {
+                                reactorPaths.add(p);
+                            }
+                        }
+                    }
+                }
+            }
+        }
+        abiBuild.setClasspathEntries(classpathPaths);
+        if (!reactorPaths.isEmpty()) {
+            abiBuild.setReactorModulePaths(reactorPaths);
+        }
+
+        // Collect annotation processor path for processor classification
+        var processorPaths = new ArrayList<Path>();
+        for (var entry : dependencies.entrySet()) {
+            if (entry.getKey() instanceof JavaPathType type) {
+                var location = type.location();
+                if (location.isPresent()
+                        && (location.get() == StandardLocation.ANNOTATION_PROCESSOR_PATH
+                                || location.get() == StandardLocation.ANNOTATION_PROCESSOR_MODULE_PATH)) {
+                    processorPaths.addAll(entry.getValue());
+                }
+            }
+        }
+        if (!processorPaths.isEmpty()) {
+            abiBuild.setProcessorPath(processorPaths);
+        }
+
+        // Hash module-info-patch.maven files for config change detection
+        abiBuild.setConfigHash(computeConfigHash(configuration));
+
+        // 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).");
+        if (mojo.showCompilationChanges && abiBuild.getRebuildCause() != null) {
+            logger.info("Rebuild cause: " + abiBuild.getRebuildCause());
+            for (Path f : toCompile) {
+                logger.info("  " + f);
+            }
+        }
+
+        var originalSourceFiles = new ArrayList<>(sourceFiles);
+        boolean success = true;
+        // Safety bound: the compile set is monotonically growing (bounded by total source count).
+        // If a bug causes processCompiledClasses to return files already compiled, this prevents
+        // an infinite loop. In practice this limit should never be reached.
+        int maxRounds = originalSourceFiles.size() + 1;
+        int rounds = 0;
+
+        try {
+            while (!toCompile.isEmpty()) {
+                if (++rounds > maxRounds) {
+                    throw new IllegalStateException("ABI cascade loop did not converge after " + maxRounds
+                            + " rounds — " + "possible dependency cycle or bug in processCompiledClasses()");
+                }
+                Set<Path> compileSet = toCompile;
+                sourceFiles = originalSourceFiles.stream()
+                        .filter(sf -> compileSet.contains(sf.file))
+                        .collect(Collectors.toList());
+
+                if (sourceFiles.isEmpty()) {
+                    break;
+                }
+
+                var compilerOutput = new StringWriter();
+                success = compileWithAbiAnalyzer(compiler, configuration, compilerOutput, abiBuild);
+                String output = compilerOutput.toString();
+                if (!output.isBlank()) {
+                    logger.warn(output);
+                }
+                if (!success) {
+                    break;
+                }
+
+                toCompile = abiBuild.processCompiledClasses(compileSet);
+                if (!toCompile.isEmpty()) {
+                    logger.info("ABI cascade: recompiling " + toCompile.size() + " additional file(s).");
+                    if (mojo.showCompilationChanges) {
+                        for (Path f : toCompile) {
+                            logger.info("  " + f);
+                        }
+                    }
+                }
+            }
+        } finally {
+            sourceFiles = originalSourceFiles;
+        }
+
+        if (success) {
+            abiBuild.finish();
+            logger.info("Compiled " + abiBuild.compiledCount() + " file(s), " + abiBuild.unchangedCount()
+                    + " unchanged (ABI strategy).");
+        } else {
+            abiBuild.invalidate();
+            throw new CompilationFailureException("Compilation failed (ABI incremental strategy).");
+        }
+    }
+
+    private String computeConfigHash(Options configuration) {
+        var digest = new StringBuilder();
+
+        // Include compiler options in the config hash so changes to -source/-target/-release
+        // etc. trigger a full rebuild under the ABI strategy.
+        String optionsRepr = String.join("|", configuration.options);
+        digest.append("opts:").append(optionsRepr).append(';');
+
+        for (SourceDirectory source : sourceDirectories) {
+            Path patchFile = source.root.resolve(ModuleInfoPatch.FILENAME);
+            if (Files.isRegularFile(patchFile)) {
+                try {
+                    byte[] content = Files.readAllBytes(patchFile);
+                    digest.append(patchFile)
+                            .append(':')
+                            .append(Sha256.hash(content))
+                            .append(';');
+                } catch (IOException e) {
+                    digest.append(patchFile).append(":unreadable;");
+                }
+            }
+        }
+        if (digest.isEmpty()) {
+            return "";
+        }
+        return Sha256.hash(digest.toString());
+    }
+
+    /**
+     * Compiles sources with the ABI analyzer attached as a TaskListener.

Review Comment:
   Fixed in 1ba1359. Javadoc updated to: "Compiles sources and performs ABI analysis on the compiled classes."



##########
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 system property
+              so it is resolved before target/classes/ by the bootstrap class loader.

Review Comment:
   Fixed in 1ba1359. Comment now reads "via the classesDirectory parameter".



-- 
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] Add ABI-fingerprint-based incremental compilation strategy [maven-compiler-plugin]

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


##########
src/main/java/org/apache/maven/plugin/compiler/incremental/BytecodeAnalyzer.java:
##########
@@ -0,0 +1,366 @@
+/*
+ * 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.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.MessageDigest;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+import java.util.TreeSet;
+
+import org.objectweb.asm.AnnotationVisitor;
+import org.objectweb.asm.ClassReader;
+import org.objectweb.asm.ClassVisitor;
+import org.objectweb.asm.FieldVisitor;
+import org.objectweb.asm.MethodVisitor;
+import org.objectweb.asm.Opcodes;
+import org.objectweb.asm.Type;
+
+/**
+ * Analyzes compiled {@code .class} files using ASM to extract type references
+ * and compute bytecode-level ABI fingerprints.
+ *
+ * <p>Type references are collected from the class hierarchy, field/method
+ * descriptors, exception types, annotations, and bytecode instructions
+ * ({@code new}, {@code checkcast}, {@code instanceof}, field/method owners).
+ *
+ * <p>The bytecode ABI reflects erased types (no generics) and is computed from
+ * non-private, non-synthetic fields and methods.
+ *
+ * @see AbiExtractor
+ * @see DependencyScanner
+ */
+public class BytecodeAnalyzer {
+
+    public record ClassAnalysis(
+            String className, String abiFingerprint, String abiCanonical, Set<String> referencedTypes) {}
+
+    public static ClassAnalysis analyze(Path classFile) throws IOException {
+        return analyze(Files.readAllBytes(classFile));
+    }
+
+    public static ClassAnalysis analyze(byte[] classBytes) {

Review Comment:
   Done in 35bb1d2 + 950b860. The plugin now uses `java.lang.classfile` on JDK 24+ and falls back to ASM on older JDKs. The JDK 24+ sources are compiled in a `java24+` activation profile, and `BytecodeAnalyzer.selectAnalyzer()` picks the right implementation reflectively at runtime.
   
   RE ABI vs bodies: the ABI fingerprint intentionally ignores method bodies — changes to bodies don't alter the public API surface, so dependents don't need recompilation. But bodies are still compiled; it's only the *change detection* for incremental cascading that's ABI-based.



-- 
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] Add ABI-fingerprint-based incremental compilation strategy [maven-compiler-plugin]

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

   Commit 950b860 addresses both issues from the latest review:
   
   1. **pom.xml** — Moved `compile-java24` execution into a `java24+` activation profile (`<jdk>[24,)</jdk>`). On JDK 17/21, the execution is skipped and only ASM is used. On JDK 24+, `ClassfileClassAnalyzer` is compiled and selected at runtime.
   
   2. **BytecodeAnalyzerTest** — Added `@EnabledForJreRange(min = JAVA_24)` and switched to reflective instantiation (same pattern as `BytecodeAnalyzer.selectAnalyzer()`) to avoid a compile-time dependency on the JDK 24+-only class.


-- 
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] Add ABI-fingerprint-based incremental compilation strategy [maven-compiler-plugin]

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


##########
src/main/java24/org/apache/maven/plugin/compiler/incremental/ClassfileClassAnalyzer.java:
##########
@@ -0,0 +1,571 @@
+/*
+ * 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.lang.classfile.Attributes;
+import java.lang.classfile.ClassFile;
+import java.lang.classfile.ClassModel;
+import java.lang.classfile.FieldModel;
+import java.lang.classfile.MethodModel;
+import java.lang.classfile.attribute.ModuleAttribute;
+import java.lang.classfile.attribute.ModuleProvideInfo;
+import java.lang.classfile.attribute.ModuleRequireInfo;
+import java.lang.classfile.instruction.FieldInstruction;
+import java.lang.classfile.instruction.InvokeInstruction;
+import java.lang.classfile.instruction.NewMultiArrayInstruction;
+import java.lang.classfile.instruction.NewObjectInstruction;
+import java.lang.classfile.instruction.TypeCheckInstruction;
+import java.lang.reflect.AccessFlag;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.TreeSet;
+
+/**
+ * {@link ClassAnalyzer} implementation using the standard {@code java.lang.classfile}
+ * API, available since JDK 24.
+ *
+ * <p>This implementation is loaded reflectively by {@link BytecodeAnalyzer} when
+ * the running JVM version is 24 or later. It is compiled separately with
+ * {@code --release 24} to avoid a compile-time dependency on the classfile API
+ * in the main sources.
+ *
+ * <p>Type references are classified into two sets:
+ * <ul>
+ *   <li><b>signatureTypes</b> — types from the public API surface: supertype, interfaces,
+ *       field/method descriptors of non-private members, exception types, annotations.
+ *       These are what downstream consumers structurally depend on.</li>
+ *   <li><b>implementationTypes</b> — types referenced only in method body bytecode
+ *       instructions (INVOKE*, field access, NEW, CHECKCAST, etc.), and descriptor
+ *       types of private members. Changes to these do not cascade to the class's
+ *       signature consumers.</li>
+ * </ul>
+ *
+ * <p>{@code module-info.class} is handled specially: its ABI fingerprint is derived
+ * from the {@code Module} attribute directives (requires, exports, opens, uses,
+ * provides), and no implementation types are collected.
+ *
+ * @see BytecodeAnalyzer
+ * @see ClassAnalyzer
+ */
+class ClassfileClassAnalyzer extends ClassAnalyzer {
+
+    @Override
+    public BytecodeAnalyzer.ClassAnalysis analyze(byte[] classBytes) {
+        ClassModel cm = ClassFile.of().parse(classBytes);
+
+        // module-info.class has the MODULE access flag
+        if (cm.flags().has(AccessFlag.MODULE)) {
+            return analyzeModuleInfo(cm);
+        }
+
+        String className = BytecodeAnalyzer.toJavaName(cm.thisClass().asInternalName());
+        Set<String> signatureTypes = new TreeSet<>();
+        Set<String> implementationTypes = new TreeSet<>();
+        Set<String> annotationTypes = new TreeSet<>();
+
+        collectTypes(cm, signatureTypes, implementationTypes, annotationTypes);
+
+        // Remove self-references and JDK types
+        signatureTypes.remove(className);
+        implementationTypes.remove(className);
+        implementationTypes.removeAll(signatureTypes); // sig takes precedence
+        signatureTypes.removeIf(ClassfileClassAnalyzer::isJdkType);
+        implementationTypes.removeIf(ClassfileClassAnalyzer::isJdkType);
+        annotationTypes.removeIf(ClassfileClassAnalyzer::isJdkType);
+
+        String abiCanonical = buildCanonical(cm);
+        String abiFingerprint = Sha256.hash(abiCanonical);
+
+        // Read the SourceFile attribute for accurate source-file attribution
+        String sourceFileName = cm.findAttribute(Attributes.sourceFile())
+                .map(sf -> sf.sourceFile().stringValue())
+                .orElse("");
+
+        return new BytecodeAnalyzer.ClassAnalysis(
+                className,
+                abiFingerprint,
+                abiCanonical,
+                signatureTypes,
+                implementationTypes,
+                annotationTypes,
+                /* moduleName= */ "",
+                /* isModuleInfo= */ false,
+                sourceFileName);
+    }
+
+    // --- module-info handling ---
+
+    private static BytecodeAnalyzer.ClassAnalysis analyzeModuleInfo(ClassModel cm) {
+        var moduleAttrOpt = cm.findAttribute(Attributes.module());
+        if (moduleAttrOpt.isEmpty()) {
+            return new BytecodeAnalyzer.ClassAnalysis(
+                    "module-info", "", "", Set.of(), Set.of(), Set.of(), "", true, "module-info.java");
+        }
+
+        ModuleAttribute mod = moduleAttrOpt.get();
+        String moduleName = mod.moduleName().name().stringValue();
+        boolean isOpen = cm.flags().has(AccessFlag.OPEN);
+
+        var requires = new TreeSet<String>();
+        var exports = new TreeSet<String>();
+        var opens = new TreeSet<String>();
+        var uses = new TreeSet<String>();
+        var provides = new TreeSet<String>();
+
+        for (ModuleRequireInfo req : mod.requires()) {
+            var sb = new StringBuilder("requires ");
+            if (req.requiresFlags().contains(AccessFlag.TRANSITIVE)) sb.append("transitive ");
+            if (req.requiresFlags().contains(AccessFlag.STATIC_PHASE)) sb.append("static ");
+            sb.append(req.requires().name().stringValue());
+            requires.add(sb.toString());
+        }
+        for (var exp : mod.exports()) {
+            var sb = new StringBuilder("exports ")
+                    .append(exp.exportedPackage().name().stringValue().replace('/', '.'));
+            var tos = exp.exportsTo();
+            if (!tos.isEmpty()) {
+                sb.append(" to ")
+                        .append(tos.stream()
+                                .map(e -> e.name().stringValue())
+                                .sorted()
+                                .reduce((a, b) -> a + ", " + b)
+                                .orElse(""));
+            }
+            exports.add(sb.toString());
+        }
+        for (var op : mod.opens()) {
+            var sb = new StringBuilder("opens ")
+                    .append(op.openedPackage().name().stringValue().replace('/', '.'));
+            var tos = op.opensTo();
+            if (!tos.isEmpty()) {
+                sb.append(" to ")
+                        .append(tos.stream()
+                                .map(e -> e.name().stringValue())
+                                .sorted()
+                                .reduce((a, b) -> a + ", " + b)
+                                .orElse(""));
+            }
+            opens.add(sb.toString());
+        }
+        for (var u : mod.uses()) {
+            uses.add("uses " + BytecodeAnalyzer.toJavaName(u.asInternalName()));
+        }
+        for (ModuleProvideInfo p : mod.provides()) {
+            var sb = new StringBuilder("provides ")
+                    .append(BytecodeAnalyzer.toJavaName(p.provides().asInternalName()));
+            var impls = p.providesWith();
+            if (!impls.isEmpty()) {
+                sb.append(" with ")
+                        .append(impls.stream()
+                                .map(i -> BytecodeAnalyzer.toJavaName(i.asInternalName()))
+                                .sorted()
+                                .reduce((a, b) -> a + ", " + b)
+                                .orElse(""));
+            }
+            provides.add(sb.toString());
+        }
+
+        var canonical = new StringBuilder();
+        if (isOpen) canonical.append("open ");
+        canonical.append("module ").append(moduleName).append('\n');
+        for (String r : requires) canonical.append("  ").append(r).append('\n');
+        for (String e : exports) canonical.append("  ").append(e).append('\n');
+        for (String o : opens) canonical.append("  ").append(o).append('\n');
+        for (String u : uses) canonical.append("  ").append(u).append('\n');
+        for (String p : provides) canonical.append("  ").append(p).append('\n');
+
+        String abiCanonical = canonical.toString();
+        String abiFingerprint = Sha256.hash(abiCanonical);
+
+        // Signature deps for module-info: service types from uses/provides
+        var sigTypes = new TreeSet<String>();
+        for (var u : mod.uses()) {
+            String name = BytecodeAnalyzer.toJavaName(u.asInternalName());
+            if (!isJdkType(name)) sigTypes.add(name);
+        }
+        for (ModuleProvideInfo p : mod.provides()) {
+            String svc = BytecodeAnalyzer.toJavaName(p.provides().asInternalName());
+            if (!isJdkType(svc)) sigTypes.add(svc);
+            for (var impl : p.providesWith()) {
+                String implName = BytecodeAnalyzer.toJavaName(impl.asInternalName());
+                if (!isJdkType(implName)) sigTypes.add(implName);
+            }
+        }
+
+        // module-info qualified name uses the MODULE_PREFIX convention
+        String qualifiedName = AbiIncrementalBuild.MODULE_PREFIX + moduleName;
+
+        return new BytecodeAnalyzer.ClassAnalysis(
+                qualifiedName,
+                abiFingerprint,
+                abiCanonical,
+                sigTypes,
+                Set.of(),
+                Set.of(),
+                moduleName,
+                /* isModuleInfo= */ true,
+                /* sourceFileName= */ "module-info.java");
+    }
+
+    // --- type reference collection ---
+
+    private static void collectTypes(
+            ClassModel cm, Set<String> signatureTypes, Set<String> implementationTypes, Set<String> annotationTypes) {
+
+        // Superclass and interfaces are always signature-level
+        cm.superclass().ifPresent(sup -> addRef(sup.asInternalName(), signatureTypes));
+        for (var iface : cm.interfaces()) {
+            addRef(iface.asInternalName(), signatureTypes);
+        }
+
+        // Class generic signature (e.g. "class Foo<T extends Bar>") — extract type args
+        cm.findAttribute(Attributes.signature())
+                .ifPresent(sig -> addGenericSignatureRefs(sig.signature().stringValue(), signatureTypes));
+
+        // Class-level annotations → signature + annotation tracking
+        collectAnnotations(
+                cm.findAttribute(Attributes.runtimeVisibleAnnotations())
+                        .map(a -> a.annotations())
+                        .orElse(List.of()),
+                signatureTypes,
+                annotationTypes);
+        collectAnnotations(
+                cm.findAttribute(Attributes.runtimeInvisibleAnnotations())
+                        .map(a -> a.annotations())
+                        .orElse(List.of()),
+                signatureTypes,
+                annotationTypes);
+
+        for (FieldModel field : cm.fields()) {
+            boolean isPrivate = field.flags().has(AccessFlag.PRIVATE);
+            Set<String> descTarget = isPrivate ? implementationTypes : signatureTypes;
+
+            addDescriptor(field.fieldType().stringValue(), descTarget);
+
+            // Field generic signature — extract concrete type arguments (e.g. Foo in List<Foo>)
+            field.findAttribute(Attributes.signature())
+                    .ifPresent(sig -> addGenericSignatureRefs(sig.signature().stringValue(), descTarget));
+
+            // Field annotations go to the same target as the field descriptor
+            collectAnnotations(
+                    field.findAttribute(Attributes.runtimeVisibleAnnotations())
+                            .map(a -> a.annotations())
+                            .orElse(List.of()),
+                    descTarget,
+                    annotationTypes);
+            collectAnnotations(
+                    field.findAttribute(Attributes.runtimeInvisibleAnnotations())
+                            .map(a -> a.annotations())
+                            .orElse(List.of()),
+                    descTarget,
+                    annotationTypes);
+        }
+
+        for (MethodModel method : cm.methods()) {
+            boolean isPrivate = method.flags().has(AccessFlag.PRIVATE);
+            Set<String> sigTarget = isPrivate ? implementationTypes : signatureTypes;
+
+            // Method descriptor types (params + return) and exceptions → sig if non-private
+            addMethodDescriptor(method.methodType().stringValue(), sigTarget);
+            method.findAttribute(Attributes.exceptions())
+                    .ifPresent(ex -> ex.exceptions().forEach(e -> addRef(e.asInternalName(), sigTarget)));
+
+            // Method generic signature — extract concrete type arguments (e.g. Foo in List<Foo>)
+            method.findAttribute(Attributes.signature())
+                    .ifPresent(sig -> addGenericSignatureRefs(sig.signature().stringValue(), sigTarget));
+
+            // Method annotations → same target as descriptor
+            collectAnnotations(
+                    method.findAttribute(Attributes.runtimeVisibleAnnotations())
+                            .map(a -> a.annotations())
+                            .orElse(List.of()),
+                    sigTarget,
+                    annotationTypes);
+            collectAnnotations(
+                    method.findAttribute(Attributes.runtimeInvisibleAnnotations())
+                            .map(a -> a.annotations())
+                            .orElse(List.of()),
+                    sigTarget,
+                    annotationTypes);
+
+            // Method body instructions → always implementation-level
+            method.code().ifPresent(code -> {
+                for (var element : code) {
+                    switch (element) {
+                        case InvokeInstruction ii -> addRef(ii.owner().asInternalName(), implementationTypes);
+                        case FieldInstruction fi -> addRef(fi.owner().asInternalName(), implementationTypes);
+                        case TypeCheckInstruction tci -> addRef(tci.type().asInternalName(), implementationTypes);
+                        case NewObjectInstruction noi -> addRef(noi.className().asInternalName(), implementationTypes);
+                        case NewMultiArrayInstruction nma ->
+                            addRef(nma.arrayType().asInternalName(), implementationTypes);
+                        default -> {}
+                    }
+                }
+            });
+        }
+
+        // Scan the constant pool for class references that are not captured by instructions.
+        // This covers compile-time constants (static final primitives) whose values are inlined
+        // by javac — the referencing class has no GETSTATIC instruction, but the resolved class
+        // is still present in the constant pool.
+        for (var entry : cm.constantPool()) {
+            if (entry instanceof java.lang.classfile.constantpool.ClassEntry ce) {
+                String internalName = ce.asInternalName();
+                // Skip array type descriptors and the class itself
+                if (!internalName.startsWith("[")
+                        && !internalName.equals(cm.thisClass().asInternalName())) {
+                    addRef(internalName, implementationTypes);
+                }
+            }
+        }
+    }
+
+    private static void collectAnnotations(
+            java.util.List<? extends java.lang.classfile.Annotation> annotations,
+            Set<String> typeTarget,
+            Set<String> annotationTarget) {
+        for (var ann : annotations) {
+            String descriptor = ann.className().stringValue();
+            String name = descriptorToJavaName(descriptor);
+            if (name != null) {
+                typeTarget.add(name);
+                annotationTarget.add(name);
+            }
+        }
+    }
+
+    // --- ABI canonical form ---
+
+    private static String buildCanonical(ClassModel cm) {
+        var fields = new ArrayList<FieldInfo>();
+        var methods = new ArrayList<MethodInfo>();
+
+        for (FieldModel field : cm.fields()) {
+            int access = accessMask(field.flags().flags());
+            if (!isPrivateOrSynthetic(access)) {
+                Object constantValue = field.findAttribute(Attributes.constantValue())
+                        .map(cv -> cv.constant().constantValue())
+                        .orElse(null);
+                String fieldSig = field.findAttribute(Attributes.signature())
+                        .map(s -> s.signature().stringValue())
+                        .orElse(null);
+                fields.add(new FieldInfo(
+                        access,
+                        field.fieldName().stringValue(),
+                        field.fieldType().stringValue(),
+                        constantValue,
+                        fieldSig));
+            }
+        }
+
+        for (MethodModel method : cm.methods()) {
+            int access = accessMask(method.flags().flags());
+            String name = method.methodName().stringValue();
+            if (!isPrivateOrSynthetic(access) && !"<clinit>".equals(name)) {
+                String methodSig = method.findAttribute(Attributes.signature())
+                        .map(s -> s.signature().stringValue())
+                        .orElse(null);
+                methods.add(new MethodInfo(access, name, method.methodType().stringValue(), methodSig));
+            }
+        }
+
+        int classAccess = accessMask(cm.flags().flags());
+        String className = BytecodeAnalyzer.toJavaName(cm.thisClass().asInternalName());
+        String classSignature = cm.findAttribute(Attributes.signature())
+                .map(s -> s.signature().stringValue())
+                .orElse(null);
+        String superName = cm.superclass()
+                .map(sup -> BytecodeAnalyzer.toJavaName(sup.asInternalName()))
+                .orElse(null);
+        List<String> ifaceNames = cm.interfaces().stream()
+                .map(iface -> BytecodeAnalyzer.toJavaName(iface.asInternalName()))
+                .toList();
+
+        return buildCanonicalForm(classAccess, className, classSignature, superName, ifaceNames, fields, methods);
+    }
+
+    // --- generic signature type reference extraction ---
+
+    /**
+     * Extracts all concrete class type references from a JVM generic signature string
+     * (JVMS §4.7.9.1) and adds them to {@code types}.
+     *
+     * <p>Examples (signature → extracted types):
+     * <ul>
+     *   <li>{@code Ljava/util/List<Lcom/example/Foo;>;} → {@code com.example.Foo}
+     *   <li>{@code Ljava/util/Map<Lcom/example/Key;Lcom/example/Val;>;} → {@code com.example.Key}, {@code com.example.Val}
+     *   <li>{@code (Lcom/example/Req;)Lcom/example/Resp;} → {@code com.example.Req}, {@code com.example.Resp}
+     *   <li>{@code TT;} (type variable) → nothing
+     *   <li>{@code +Lcom/Foo;} (wildcard) → {@code com.Foo}
+     * </ul>
+     *
+     * <p>Type variables ({@code TName;}) and primitive types are intentionally skipped —
+     * they are not concrete dependencies.
+     */
+    private static void addGenericSignatureRefs(String sig, Set<String> types) {
+        if (sig == null || sig.isEmpty()) {
+            return;
+        }
+        int[] pos = {0};
+        parseSig(sig, pos, types);
+    }
+
+    /**
+     * Recursive descent parser for JVM generic signatures.
+     * Advances {@code pos[0]} as it consumes characters.
+     */
+    private static void parseSig(String sig, int[] pos, Set<String> types) {
+        while (pos[0] < sig.length()) {
+            char c = sig.charAt(pos[0]);
+            switch (c) {
+                case 'L' -> parseClassTypeSignature(sig, pos, types);
+                case 'T' -> {
+                    // TypeVariableSignature: T<Identifier>; — skip, it's a generic parameter, not a concrete type
+                    pos[0]++; // skip 'T'
+                    while (pos[0] < sig.length() && sig.charAt(pos[0]) != ';') {
+                        pos[0]++;
+                    }
+                    if (pos[0] < sig.length()) pos[0]++; // skip ';'
+                }

Review Comment:
   🔴 **Bug — FormalTypeParameter bounds missed for 'T'-prefixed type params.**
   
   The `case 'T'` handler skips everything until `;`, but inside `FormalTypeParameters` (e.g. `<T:Lbound;>`), the type param name `T` is followed by `:` (not `;`). The handler consumes `:Lcom/example/MyBound` as part of the "type variable name", and the bound is never parsed as a dependency.
   
   **Concrete failure:** `<T:Lcom/example/MyBound;>` → `MyBound` is lost. Any type param name starting with 'T' triggers this.
   
   **Impact:** Under-cascading — ABI changes to `MyBound` won't recompile consumers of `class Container<T extends MyBound>`. This is the category of bug the incremental compiler must never have.
   
   Fix: check for `:` in addition to `;` to detect FormalTypeParameter context:
   
   ```suggestion
                   case 'T' -> {
                       // TypeVariableSignature: T<Identifier>; — skip, not a concrete type.
                       // BUT: in FormalTypeParameters, 'T' could be the parameter NAME followed
                       // by ':' (e.g. <T:Lbound;>). Detect ':' to avoid consuming the ClassBound.
                       pos[0]++; // skip 'T'
                       while (pos[0] < sig.length()) {
                           char tc = sig.charAt(pos[0]);
                           if (tc == ';') {
                               pos[0]++; // end of TypeVariableSignature — consume ';'
                               break;
                           } else if (tc == ':') {
                               // FormalTypeParameter: ':' separates identifier from ClassBound.
                               // Stop here — let the outer loop handle ':' then 'L' (parse bound).
                               break;
                           }
                           pos[0]++;
                       }
                   }
   ```



##########
src/test/java/org/apache/maven/plugin/compiler/incremental/BytecodeAnalyzerTest.java:
##########
@@ -0,0 +1,303 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.plugin.compiler.incremental;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class BytecodeAnalyzerTest {
+
+    @TempDir
+    Path sourceDir;
+
+    @TempDir
+    Path outputDir;
+
+    @Test
+    void descriptorToReadableVoid() {
+        assertEquals("void", BytecodeAnalyzer.descriptorToReadable("V"));
+    }
+
+    @Test
+    void descriptorToReadableBoolean() {
+        assertEquals("boolean", BytecodeAnalyzer.descriptorToReadable("Z"));
+    }
+
+    @Test
+    void descriptorToReadableByte() {
+        assertEquals("byte", BytecodeAnalyzer.descriptorToReadable("B"));
+    }
+
+    @Test
+    void descriptorToReadableChar() {
+        assertEquals("char", BytecodeAnalyzer.descriptorToReadable("C"));
+    }
+
+    @Test
+    void descriptorToReadableShort() {
+        assertEquals("short", BytecodeAnalyzer.descriptorToReadable("S"));
+    }
+
+    @Test
+    void descriptorToReadableInt() {
+        assertEquals("int", BytecodeAnalyzer.descriptorToReadable("I"));
+    }
+
+    @Test
+    void descriptorToReadableLong() {
+        assertEquals("long", BytecodeAnalyzer.descriptorToReadable("J"));
+    }
+
+    @Test
+    void descriptorToReadableFloat() {
+        assertEquals("float", BytecodeAnalyzer.descriptorToReadable("F"));
+    }
+
+    @Test
+    void descriptorToReadableDouble() {
+        assertEquals("double", BytecodeAnalyzer.descriptorToReadable("D"));
+    }
+
+    @Test
+    void descriptorToReadableObject() {
+        assertEquals("java.lang.String", BytecodeAnalyzer.descriptorToReadable("Ljava/lang/String;"));
+    }
+
+    @Test
+    void descriptorToReadableArray() {
+        assertEquals("int[]", BytecodeAnalyzer.descriptorToReadable("[I"));
+        assertEquals("java.lang.String[]", BytecodeAnalyzer.descriptorToReadable("[Ljava/lang/String;"));
+    }
+
+    @Test
+    void descriptorToReadableEmpty() {
+        assertEquals("", BytecodeAnalyzer.descriptorToReadable(""));
+    }
+
+    @Test
+    void parseParamsMultiple() {
+        assertEquals("java.lang.String, int", BytecodeAnalyzer.parseParams("(Ljava/lang/String;I)V"));
+    }
+
+    @Test
+    void parseParamsEmpty() {
+        assertEquals("", BytecodeAnalyzer.parseParams("()V"));
+    }
+
+    @Test
+    void parseReturnType() {
+        assertEquals("java.lang.String", BytecodeAnalyzer.parseReturn("(I)Ljava/lang/String;"));
+        assertEquals("void", BytecodeAnalyzer.parseReturn("()V"));
+        assertEquals("int", BytecodeAnalyzer.parseReturn("()I"));
+    }
+
+    @Test
+    void analyzePathProducesCorrectClassName() throws Exception {
+        CompilerTestHelper.writeSource(
+                sourceDir,
+                "test",
+                "Hello",
+                "package test; public class Hello { public String greet() { return \"hi\"; } }");
+        CompilerTestHelper.compileAndAnalyze(sourceDir, outputDir);
+
+        Path classFile = outputDir.resolve("test/Hello.class");
+        assertTrue(Files.exists(classFile));
+
+        var analysis = BytecodeAnalyzer.analyze(classFile);
+        assertEquals("test.Hello", analysis.className());
+        assertNotNull(analysis.abiFingerprint());
+        assertNotNull(analysis.abiCanonical());
+    }
+
+    @Test
+    void analyzeBytesEquivalentToAnalyzePath() throws Exception {
+        CompilerTestHelper.writeSource(
+                sourceDir, "test", "Hello", "package test; public class Hello { public int value() { return 42; } }");
+        CompilerTestHelper.compileAndAnalyze(sourceDir, outputDir);
+
+        Path classFile = outputDir.resolve("test/Hello.class");
+        var fromPath = BytecodeAnalyzer.analyze(classFile);
+        var fromBytes = BytecodeAnalyzer.analyze(Files.readAllBytes(classFile));
+
+        assertEquals(fromPath.className(), fromBytes.className());
+        assertEquals(fromPath.abiFingerprint(), fromBytes.abiFingerprint());
+        assertEquals(fromPath.abiCanonical(), fromBytes.abiCanonical());
+        assertEquals(fromPath.signatureTypes(), fromBytes.signatureTypes());
+        assertEquals(fromPath.implementationTypes(), fromBytes.implementationTypes());
+    }
+
+    @Test
+    void genericTypeChangeAffectsFingerprint() throws Exception {
+        // Compile with List<String>
+        CompilerTestHelper.writeSource(sourceDir, "test", "Generics", """
+                package test;
+                public class Generics {
+                    public java.util.List<String> getNames() { return null; }
+                    public java.util.Map<String, Integer> getMap() { return null; }
+                }
+                """);
+        CompilerTestHelper.compileAndAnalyze(sourceDir, outputDir);
+        var analysis1 = BytecodeAnalyzer.analyze(outputDir.resolve("test/Generics.class"));
+
+        // Recompile with List<Integer> — erased descriptor is identical,
+        // but generic signature differs
+        Path srcFile = sourceDir.resolve("test/Generics.java");
+        Files.writeString(srcFile, """
+                package test;
+                public class Generics {
+                    public java.util.List<Integer> getNames() { return null; }
+                    public java.util.Map<String, Integer> getMap() { return null; }
+                }
+                """);
+        CompilerTestHelper.compileAndAnalyze(sourceDir, outputDir);
+        var analysis2 = BytecodeAnalyzer.analyze(outputDir.resolve("test/Generics.class"));
+
+        assertNotEquals(
+                analysis1.abiFingerprint(),
+                analysis2.abiFingerprint(),
+                "Changing List<String> to List<Integer> should change bytecode ABI fingerprint");
+        assertTrue(analysis2.abiCanonical().contains("<sig:"), "Canonical form should include generic signatures");
+    }
+
+    @Test
+    void classLevelGenericSignatureAffectsFingerprint() throws Exception {
+        CompilerTestHelper.writeSource(sourceDir, "test", "Box", """
+                package test;
+                public class Box<T> {
+                    public T get() { return null; }
+                }
+                """);
+        CompilerTestHelper.compileAndAnalyze(sourceDir, outputDir);
+        var analysis1 = BytecodeAnalyzer.analyze(outputDir.resolve("test/Box.class"));
+
+        Files.writeString(sourceDir.resolve("test/Box.java"), """
+                package test;
+                public class Box<T extends Comparable<T>> {
+                    public T get() { return null; }
+                }
+                """);
+        CompilerTestHelper.compileAndAnalyze(sourceDir, outputDir);
+        var analysis2 = BytecodeAnalyzer.analyze(outputDir.resolve("test/Box.class"));
+
+        assertNotEquals(
+                analysis1.abiFingerprint(),
+                analysis2.abiFingerprint(),
+                "Changing type parameter bounds should change bytecode ABI fingerprint");
+    }
+
+    @Test
+    @org.junit.jupiter.api.condition.EnabledForJreRange(min = org.junit.jupiter.api.condition.JRE.JAVA_24)
+    void genericTypeArgumentsTrackedAsDependencies() throws Exception {
+        // Foo is a user-defined type used only as a generic type argument
+        CompilerTestHelper.writeSource(sourceDir, "test", "Foo", "package test; public class Foo {}");
+        CompilerTestHelper.writeSource(sourceDir, "test", "Bar", "package test; public class Bar {}");
+        CompilerTestHelper.writeSource(sourceDir, "test", "Subject", """
+                package test;
+                import java.util.List;
+                import java.util.Map;
+                import java.util.function.Function;
+                public class Subject {
+                    // Foo appears only as a type argument — not in the erased descriptor
+                    public List<Foo> getItems() { return null; }
+                    // Bar appears as a Map value type argument
+                    public Map<String, Bar> getMap() { return null; }
+                    // Wildcards: Foo as lower bound
+                    public List<? extends Foo> getBounded() { return null; }
+                    // Foo in field generic signature
+                    public java.util.Optional<Foo> optFoo = java.util.Optional.empty();
+                }
+                """);
+        CompilerTestHelper.compileAndAnalyze(sourceDir, outputDir);
+
+        var analysis = BytecodeAnalyzer.analyze(outputDir.resolve("test/Subject.class"));
+
+        assertTrue(
+                analysis.signatureTypes().contains("test.Foo"),
+                "test.Foo used as generic type arg should be in signatureTypes; got: " + analysis.signatureTypes());
+        assertTrue(
+                analysis.signatureTypes().contains("test.Bar"),
+                "test.Bar used as generic type arg should be in signatureTypes; got: " + analysis.signatureTypes());
+    }
+
+    @Test
+    @org.junit.jupiter.api.condition.EnabledForJreRange(min = org.junit.jupiter.api.condition.JRE.JAVA_24)
+    void genericSuperclassTypeArgsTracked() throws Exception {
+        CompilerTestHelper.writeSource(sourceDir, "test", "Item", "package test; public class Item {}");
+        CompilerTestHelper.writeSource(sourceDir, "test", "ItemList", """
+                package test;
+                import java.util.AbstractList;
+                // Item appears only in the class generic signature (extends AbstractList<Item>)
+                public class ItemList extends AbstractList<Item> {
+                    @Override public Item get(int i) { return null; }
+                    @Override public int size() { return 0; }
+                }
+                """);
+        CompilerTestHelper.compileAndAnalyze(sourceDir, outputDir);
+
+        var analysis = BytecodeAnalyzer.analyze(outputDir.resolve("test/ItemList.class"));
+
+        assertTrue(
+                analysis.signatureTypes().contains("test.Item"),
+                "test.Item as superclass type arg should be in signatureTypes; got: " + analysis.signatureTypes());
+    }
+

Review Comment:
   💡 **Missing test for FormalTypeParameter bounds.** Both new tests cover type *arguments* (`List<Foo>`, `extends AbstractList<Item>`) but not type parameter *bounds* (`class Container<T extends MyBound>`). Add a test that compiles a class like:
   
   ```java
   public class Container<T extends MyBound> { ... }
   ```
   
   and asserts that `MyBound` appears in `signatureTypes` — this would catch the `parseSig` bug above.



-- 
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] refactor: simplify annotation processor incremental handling [maven-compiler-plugin]

Posted by "gnodet (via GitHub)" <gi...@apache.org>.
gnodet closed pull request #1144: refactor: simplify annotation processor incremental handling
URL: https://github.com/apache/maven-compiler-plugin/pull/1144


-- 
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] Add ABI-fingerprint-based incremental compilation strategy [maven-compiler-plugin]

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


##########
src/main/java/org/apache/maven/plugin/compiler/ToolExecutor.java:
##########
@@ -915,6 +919,201 @@ 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 {
+        if (compiler instanceof ForkedTool) {
+            logger.warn("ABI incremental strategy is not supported with forked compilation."
+                    + " Falling back to full compilation.");
+        }
+        var abiBuild = new AbiIncrementalBuild(outputDirectory);

Review Comment:
   Fixed in e79f971. Calling `abiBuild.invalidate()` right after construction when `ForkedTool` is detected, so `initialize()` deletes the stale state file and returns all files — making the full compilation fallback actually work.



-- 
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] Add ABI-fingerprint-based incremental compilation strategy [maven-compiler-plugin]

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

   @rmannibucau Thanks for the follow-up.
   
   **ASM removal:** Commit 8aec6e4 moved in that direction — the build now requires JDK 25+ (enforcer) and `ClassfileClassAnalyzer` is always compiled. However, ASM is still kept as a runtime fallback in `selectAnalyzer()` for the edge case where the plugin JAR ends up running on JDK < 24 (which shouldn't happen with the enforcer, but belt-and-suspenders). If you'd prefer to hard-drop ASM entirely and make JDK 24+ a strict runtime requirement too, I can remove `AsmClassAnalyzer` and simplify `selectAnalyzer()` to just instantiate `ClassfileClassAnalyzer` directly. That would also let us drop the ASM dependency from this module (though it's still used by `ByteCodeTransformer` for the JDK-8318913 workaround — separate concern).
   
   Would you prefer the hard-drop approach?
   
   **Centralised state:** Agreed that having a single state file carrying both ABI fingerprints and module-level metadata would be cleaner than having two separate incremental mechanisms. The current implementation already stores everything in a single versioned binary state file (`AbiIncrementalBuild.State`), so extending it with module-level info (exports, requires, etc.) should be straightforward. I'd prefer to track that as a follow-up issue though — the current PR is already substantial, and the module integration is an additive feature. Sound good?


-- 
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] Add ABI-fingerprint-based incremental compilation strategy [maven-compiler-plugin]

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


##########
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
+     *       order, generic type parameters) of each compiled type and only recompiles consumers
+     *       whose dependency's ABI actually changed. Method body changes do not cascade.
+     *       Cross-module ABI changes are detected via manifest files written in the output
+     *       directory. Full JPMS support including {@code module-info.java} fingerprinting
+     *       and {@code module-info-patch.maven} tracking.
+     *       <p>Note: the ABI strategy has its own change detection and does not use
+     *       {@code staleMillis}, {@code incrementalExcludes}, or the
+     *       {@link #incrementalCompilation} aspects. Forked compilation ({@code fork=true})
+     *       falls back to full compilation since the ABI analyzer requires in-process javac.
+     *       Setting {@link #useIncrementalCompilation} to {@code false} disables this
+     *       strategy and forces a full rebuild.</p></li>
+     * </ul>
+     *
+     * @since 4.0.0-beta-7
+     */
+    @Parameter(property = "maven.compiler.incrementalStrategy", defaultValue = "timestamp")
+    protected String incrementalStrategy;

Review Comment:
   Should be merged with the existing `incrementalCompilation` field (potentially renamed).



-- 
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] Add ABI-fingerprint-based incremental compilation strategy [maven-compiler-plugin]

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


##########
src/main/java/org/apache/maven/plugin/compiler/incremental/BytecodeAnalyzer.java:
##########
@@ -0,0 +1,173 @@
+/*
+ * 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.Path;
+import java.util.ArrayList;
+import java.util.Set;
+
+/**
+ * Facade for analyzing compiled {@code .class} files to extract type references
+ * and compute bytecode-level ABI fingerprints.
+ *
+ * <p>This class is part of a multi-release JAR. The root implementation (loaded on
+ * JDK &lt; 24) always returns {@code false} from {@link #isAvailable()} — ABI
+ * fingerprinting is not supported on JDK 17–23. The {@code META-INF/versions/24/}
+ * override (loaded automatically by the JVM on JDK 24+) provides the real
+ * implementation backed by the standard {@code java.lang.classfile} API.
+ *
+ * <p>Callers must check {@link #isAvailable()} before calling {@link #analyze}.
+ * When unavailable, the ABI incremental strategy falls back to the timestamp strategy.
+ *
+ * <p>The {@link ClassAnalysis} record carries the class name, ABI fingerprint,
+ * human-readable canonical form, and two classified sets of type references:
+ * {@link ClassAnalysis#signatureTypes()} (API surface) and
+ * {@link ClassAnalysis#implementationTypes()} (method body instructions only).
+ */
+public final class BytecodeAnalyzer {
+
+    /**
+     * Analysis result for a single {@code .class} file.
+     *
+     * @param className           fully-qualified class name (dot-separated)
+     * @param abiFingerprint      16-character hex SHA-256 prefix of the ABI canonical form
+     * @param abiCanonical        human-readable representation of the public API surface
+     * @param signatureTypes      types appearing in public API surface (method/field descriptors,
+     *                            supertype, interfaces, exception types, annotation types)
+     * @param implementationTypes types appearing only in method body bytecode instructions
+     *                            (INVOKEVIRTUAL, NEW, CHECKCAST, field owners, etc.)
+     * @param annotationTypes     fully-qualified names of annotation types present on the class
+     *                            or its members, used for annotation processor cascade decisions
+     * @param moduleName          Java module name, or empty string if unnamed or non-modular
+     * @param isModuleInfo        {@code true} if this represents {@code module-info.class}
+     * @param sourceFileName      simple source file name from the {@code SourceFile} class file
+     *                            attribute (e.g. {@code "Foo.java"}); empty string if absent
+     */
+    public record ClassAnalysis(
+            String className,
+            String abiFingerprint,
+            String abiCanonical,
+            Set<String> signatureTypes,
+            Set<String> implementationTypes,
+            Set<String> annotationTypes,
+            String moduleName,
+            boolean isModuleInfo,
+            String sourceFileName) {}
+
+    private BytecodeAnalyzer() {}
+
+    /**
+     * Returns {@code true} if ABI fingerprinting is available on the running JVM.
+     *
+     * <p>This method returns {@code false} in the root JAR (JDK &lt; 24). The
+     * {@code META-INF/versions/24/} override returns {@code true}.
+     *
+     * <p>When this returns {@code false}, callers should fall back to the timestamp
+     * incremental strategy and log a warning to the user.
+     *
+     * @return {@code true} if {@link #analyze} can be called safely
+     */
+    public static boolean isAvailable() {
+        return false;
+    }
+
+    /**
+     * Analyzes the class file at {@code classFile}.
+     *
+     * <p>Only call this method after confirming {@link #isAvailable()} returns {@code true}.
+     *
+     * @param classFile path to the {@code .class} file
+     * @return analysis result
+     * @throws IOException if reading the file fails
+     * @throws UnsupportedOperationException if called on JDK &lt; 24
+     */
+    public static ClassAnalysis analyze(Path classFile) throws IOException {
+        throw new UnsupportedOperationException("ABI fingerprinting requires JDK 24 or later (running JDK "
+                + Runtime.version().feature()
+                + "). Check BytecodeAnalyzer.isAvailable() before calling analyze().");
+    }
+
+    /**
+     * Analyzes the given class file bytes.
+     *
+     * <p>Only call this method after confirming {@link #isAvailable()} returns {@code true}.
+     *
+     * @param classBytes raw {@code .class} file content
+     * @return analysis result
+     * @throws UnsupportedOperationException if called on JDK &lt; 24
+     */
+    public static ClassAnalysis analyze(byte[] classBytes) {
+        throw new UnsupportedOperationException("ABI fingerprinting requires JDK 24 or later (running JDK "
+                + Runtime.version().feature()
+                + "). Check BytecodeAnalyzer.isAvailable() before calling analyze().");
+    }
+
+    // --- package-private utilities shared by the analyzer implementations and tests ---
+
+    static String descriptorToReadable(String desc) {
+        if (desc.isEmpty()) {
+            return desc;
+        }
+        return switch (desc.charAt(0)) {
+            case 'V' -> "void";
+            case 'Z' -> "boolean";
+            case 'B' -> "byte";
+            case 'C' -> "char";
+            case 'S' -> "short";
+            case 'I' -> "int";
+            case 'J' -> "long";
+            case 'F' -> "float";
+            case 'D' -> "double";
+            case 'L' -> toJavaName(desc.substring(1, desc.indexOf(';')));
+            case '[' -> descriptorToReadable(desc.substring(1)) + "[]";
+            default -> desc;
+        };
+    }
+
+    static String parseParams(String methodDesc) {

Review Comment:
   Same comment as above: we could keep the internal method signature as stored in the JVM and remove all this conversion to human-readable form.



-- 
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] Add ABI-fingerprint-based incremental compilation strategy [maven-compiler-plugin]

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

   You're right to call out both the factual error and the design point.
   
   The answer to feedback 1 was incorrect. The `DEPENDENCIES` aspect detects that a classpath JAR changed (by mtime) and triggers a **full rebuild** of all sources — it is not a dependency graph. There is no tracking of which source files use which classes. The response conflated two different things and was wrong.
   
   Your two axes are correct and orthogonal:
   
   - **Dependency graph**: replace the module-level "recompile everything" with selective recompilation — track which source files transitively depend on a changed class, recompile only those. Change detection is still by content hash or mtime; any change to a class cascades to all its source-level consumers.
   - **ABI fingerprinting**: prune the cascade further — only recompile consumers of a class whose *public API surface* actually changed. Private body changes produce no cascade.
   
   Both are genuinely useful independently. A dependency graph without ABI is already a significant improvement over today's module-level rebuild for projects where most changes touch isolated classes.
   
   To make the review more tractable, I'm splitting this into three PRs:
   
   1. **MRJAR infrastructure** — `BytecodeAnalyzer` with `java.lang.classfile` on JDK 24+, ASM fallback on older JDKs, proper multi-release JAR packaging. No behavior change.
   2. **Dependency graph strategy** (`incrementalStrategy=graph`) — post-compilation bytecode walk to extract class-level deps, content-hash change detection, cascade on any class change. No ABI.
   3. **ABI fingerprinting** (`incrementalStrategy=abi`) — adds the public API surface comparison on top of the graph; private body changes no longer cascade.
   
   On merging `.abi-incremental-state` with the existing timestamp state file: the two formats currently serve different strategies and have different contents (the graph/ABI state is richer). Happy to discuss a unified format — the key question is whether the graph strategy should *replace* the timestamp strategy or coexist alongside it as a separate opt-in. I'd lean toward coexistence for now, with a migration path later.


-- 
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] refactor: simplify annotation processor incremental handling [maven-compiler-plugin]

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


##########
src/main/java/org/apache/maven/plugin/compiler/incremental/ExternalAbiResolver.java:
##########
@@ -0,0 +1,252 @@
+/*
+ * 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;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/**
+ * 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 maven-compiler-plugin.</li>

Review Comment:
   Okay, it answers my previous question about who would use the manifest. But if this is about adding the manifest inside the JAR file, this is a whole new topic. It brings the question of our level of commitment to this format, the compatibility with future versions, etc. Can we leave that for a separated pull request?



-- 
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] Add ABI-fingerprint-based incremental compilation strategy [maven-compiler-plugin]

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


##########
src/main/java/org/apache/maven/plugin/compiler/incremental/AbiManifest.java:
##########
@@ -0,0 +1,116 @@
+/*
+ * 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.Files;
+import java.nio.file.Path;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+
+/**
+ * Reads and writes ABI fingerprint manifests for cross-module incremental
+ * compilation.
+ *
+ * <p>After compiling a module, the incremental engine writes a manifest
+ * listing every public type and its ABI fingerprint. Downstream modules
+ * read this manifest to detect whether their dependency's ABI changed
+ * without re-analyzing bytecode.

Review Comment:
   But which Maven plugins would use that?



-- 
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] Add ABI-fingerprint-based incremental compilation strategy [maven-compiler-plugin]

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

   The [answer to feedback 1](https://github.com/apache/maven-compiler-plugin/pull/1144#pullrequestreview-5406649762) seems strange (maybe it was generated by AI)? Dependency tracking never existed before this pull request. We only had a direct, one-to-one, relationship between modified files (as detected by timestamp) and recompiled files. We could very well add dependency graph without ABI. They are two orthogonal axes:
   
   * Dependency graph replaces the _one-to-one_ relationship between modified files and recompiled files by a _one-to-many_ relationship, but the modified files can still be detected by timestamp.
   * ABI replaces "modified files" by "_effectively_ modified files" (from the point of view of a consumer of the API).
   
   Committing the two concepts in two steps would make the reviews easier. For example, one thing that I would like to check is whether the `.abi-incremental-state` file created by this pull request could be merged with the existing file generated by the current version based on timestamps.


-- 
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] Add ABI-fingerprint-based incremental compilation strategy [maven-compiler-plugin]

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


##########
src/it/abi-incremental-cascade/src/main/java/api/Model.java:
##########
@@ -0,0 +1,31 @@
+/*
+ * 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 api;
+
+public class Model {
+    private String name;
+
+    public String getName() {
+        return name;
+    }
+
+    public void setName(String name) {
+        this.name = name;
+    }
+}

Review Comment:
   🔴 **Critical:** The `// ABI_MARKER` comment was removed here, but the Groovy script in `pom.xml` (line 56) still does `text.replace('// ABI_MARKER', 'public boolean isValid() { ... }')`. Since `String.replace()` silently returns the original string when the target isn't found, step 2 of the IT will appear to succeed but won't actually modify `Model.java` — step 3's incremental build will see no source changes and the cascade behavior goes untested.
   
   The IT becomes a false green: it passes but tests nothing.
   
   Either restore the marker:
   
   ```suggestion
       }
   
       // ABI_MARKER
   }
   ```
   
   Or update the Groovy script in `pom.xml` to use a different insertion strategy (e.g. `text.replace('}\n}', 'public boolean isValid() { return name != null; }\n}\n}')`).



-- 
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] Add ABI-fingerprint-based incremental compilation strategy [maven-compiler-plugin]

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

   Thanks for the review @rmannibucau. Two points:
   
   **ASM vs ClassFile API:** Replied inline — the short version is that the ClassFile API (`java.lang.classfile`) was only finalized in JDK 24, and we target JDK 17. ASM is already a dependency (`ByteCodeTransformer`). Happy to add a dual-path implementation (ClassFile API preferred on JDK 24+, ASM fallback) if you think it's worth the added complexity now, or track it as a follow-up issue.
   
   **"does ABI work since it ignores bodies?":** Yes, that's exactly the point — and it's well-established. ABI (Application Binary Interface) fingerprinting deliberately ignores method bodies because downstream dependents can only depend on the *signatures*, not the implementation. If you change a method body but keep the same return type, parameter types, exceptions, and modifiers, no downstream class needs to be recompiled — the JVM spec guarantees binary compatibility. This is the same principle used by:
   - Gradle's ABI-based incremental compilation (since Gradle 3.4)
   - Eclipse JDT's structural delta detection
   - Zinc (sbt) incremental compiler
   
   The fingerprint covers: class modifiers, superclass/interfaces, non-private fields (types + constant values for inlined `static final`), non-private methods (return type, parameter types, exception types, modifiers), and annotations. A change to any of those means the ABI changed → dependents are recompiled. A change to only method bodies → fingerprint stays the same → dependents are *not* recompiled → faster builds.
   
   The `AbiExtractor` (javac element API, used during compilation) and `BytecodeAnalyzer` (ASM, used for external deps) both produce canonical forms that capture exactly this surface.


-- 
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] Add ABI-fingerprint-based incremental compilation strategy [maven-compiler-plugin]

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


##########
src/main/java/org/apache/maven/plugin/compiler/incremental/BytecodeAnalyzer.java:
##########
@@ -0,0 +1,366 @@
+/*
+ * 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.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.MessageDigest;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+import java.util.TreeSet;
+
+import org.objectweb.asm.AnnotationVisitor;
+import org.objectweb.asm.ClassReader;
+import org.objectweb.asm.ClassVisitor;
+import org.objectweb.asm.FieldVisitor;
+import org.objectweb.asm.MethodVisitor;
+import org.objectweb.asm.Opcodes;
+import org.objectweb.asm.Type;
+
+/**
+ * Analyzes compiled {@code .class} files using ASM to extract type references
+ * and compute bytecode-level ABI fingerprints.
+ *
+ * <p>Type references are collected from the class hierarchy, field/method
+ * descriptors, exception types, annotations, and bytecode instructions
+ * ({@code new}, {@code checkcast}, {@code instanceof}, field/method owners).
+ *
+ * <p>The bytecode ABI reflects erased types (no generics) and is computed from
+ * non-private, non-synthetic fields and methods.
+ *
+ * @see AbiExtractor
+ * @see DependencyScanner
+ */
+public class BytecodeAnalyzer {
+
+    public record ClassAnalysis(
+            String className, String abiFingerprint, String abiCanonical, Set<String> referencedTypes) {}
+
+    public static ClassAnalysis analyze(Path classFile) throws IOException {
+        return analyze(Files.readAllBytes(classFile));
+    }
+
+    public static ClassAnalysis analyze(byte[] classBytes) {

Review Comment:
   Valid concern — ASM version churn is a recurring tax. However, the ClassFile API (`java.lang.classfile`) was only finalized in JDK 24 (JEP 484), and the plugin targets JDK 17 (`<javaVersion>17</javaVersion>`). We can't use it as the primary implementation today.
   
   That said, the fallback-with-warning approach you suggest is feasible: we could detect JDK 24+ at runtime and prefer the ClassFile API, falling back to ASM on older JDKs (with a warning). This would be a natural follow-up once the ClassFile API has more adoption.
   
   Note that ASM is already a dependency of the plugin — `ByteCodeTransformer` uses it for the JDK-8318913 workaround (module-info.class patching). So this PR doesn't introduce a new dependency, it just adds another consumer.
   
   For the immediate concern (ASM not supporting newer class file versions): ASM 9.x supports up to JDK 24 class files, and the `BytecodeAnalyzer` only reads structural metadata (no code transformation), so it's relatively resilient to format changes. But I agree that migrating to ClassFile API when possible is the right direction.
   
   Happy to add a TODO/issue tracking this, or if you feel strongly, I can implement the dual-path (ClassFile API preferred, ASM fallback) in this PR — though it would add complexity.



-- 
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] Add ABI-fingerprint-based incremental compilation strategy [maven-compiler-plugin]

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


##########
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:
   ⚠️ **Warning:** The `sourceFiles` field is mutated inside the loop (line 974) and restored after it (line 998), but the restore only happens inside the `if (success)` block. If compilation throws an unexpected exception (e.g., `UncheckedIOException` from file I/O during `compileWithAbiAnalyzer`), the field is left pointing at a filtered subset. Subsequent operations on this `ToolExecutor` instance would operate on stale/wrong data.
   
   The restore should be in a `finally` block:
   
   ```java
   var originalSourceFiles = new ArrayList<>(sourceFiles);
   try {
       // ... compilation loop ...
   } finally {
       sourceFiles = originalSourceFiles;
   }
   ```



##########
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:
   ⚠️ **Warning (ast-grep confirmed):** Catching `Exception` here silently swallows everything — including `NullPointerException`, `ClassCastException`, or `IndexOutOfBoundsException` that would indicate real bugs in the dependency scanning logic.
   
   `Trees.getElement()` can legitimately return null or throw for synthetic elements, error types, or unresolved symbols. But the catch should be narrower — at minimum `IllegalArgumentException` which javac throws for invalid tree paths, or the catch should log at FINE/DEBUG level so real bugs don't disappear silently.
   
   ```suggestion
           try {
               return trees.getElement(path);
           } catch (IllegalArgumentException | NullPointerException e) {
               // javac may throw for synthetic elements, error types, or unresolved symbols
               return null;
           }
   ```



##########
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:
   💡 **Suggestion:** The `// Classpath entry unreadable — skip to next` comment is fine, but silently swallowing I/O errors on classpath entries could mask real configuration problems (e.g., a reactor module's target/classes doesn't exist because it wasn't built). Consider logging at debug/fine 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:
   💡 **Note:** The `DependencyScanner` is created per `finished()` callback (one per type element). For a single compilation unit with multiple top-level types (rare but legal in Java — you can have a public class and package-private classes in the same file), the scanner scans the entire compilation unit AST each time. The deps from all types in the CU get merged because the scanner doesn't filter by the specific TypeElement being analyzed.
   
   This means if `Foo.java` contains `public class Foo` and `class FooHelper`, both will report the union of all deps from the entire CU, not just their own. This could cause over-cascading (false recompilation) but not under-cascading (missed recompilation), so it's safe but wasteful.



##########
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:
   💡 **Note:** The TODO is honest and worth tracking. Inner/nested classes produce `Foo$Bar.class` files but their qualified names use dots (`Foo.Bar`). The `replace('.', '/')` here would produce `Foo/Bar.class` instead of `Foo$Bar.class`, leaving stale class files on disk.
   
   While javac's ANALYZE phase fires for top-level compilation units, there's a subtle case: if a source file previously defined a top-level class `Foo` and a package-level class `Bar` in the same file, and on the next compile `Bar` is removed, this method would be called for `Bar` and would work correctly. The actual risk is for `Foo.Bar` inner classes from previous compilations — but those class files would become stale on disk if the inner class is removed.
   
   Consider also handling the `$` convention:
   ```java
   private void deleteClassFile(String qualifiedName) {
       // Handle both top-level (com.Foo -> com/Foo.class) and
       // inner classes (com.Foo.Bar -> com/Foo$Bar.class)
       String relativePath = qualifiedName.replace('.', '/');
       Path classFile = outputDir.resolve(relativePath + ".class");
       try {
           Files.deleteIfExists(classFile);
       } catch (IOException e) {
           throw new UncheckedIOException("Failed to delete stale class file: " + classFile, e);
       }
       // Also try inner class naming convention
       int lastDot = qualifiedName.lastIndexOf('.');
       if (lastDot > 0) {
           String withDollar = qualifiedName.substring(0, lastDot).replace('.', '/') 
               + "$" + qualifiedName.substring(lastDot + 1) + ".class";
           try {
               Files.deleteIfExists(outputDir.resolve(withDollar));
           } catch (IOException e) {
               // Best effort for inner class cleanup
           }
       }
   }
   ```



##########
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:
   💡 **Note:** `initialize()` reads ALL source files into memory to compute SHA-256 hashes (line 134, `hashSourceFiles`). For a large project with thousands of source files, this means reading every source file on every build to compute content hashes — even when nothing changed.
   
   The existing timestamp-based `IncrementalBuild` avoids this by comparing modification times first. Consider a two-phase approach: check `lastModifiedTime` first and only hash files whose mtime changed. This would significantly reduce I/O for the common case of "nothing changed."



-- 
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] Add ABI-fingerprint-based incremental compilation strategy [maven-compiler-plugin]

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


##########
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,
+     * 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>();
+        var reactorPaths = new LinkedHashSet<Path>();
+        for (var entry : dependencies.entrySet()) {
+            if (entry.getKey() instanceof JavaPathType type) {
+                var location = type.location();
+                if (location.isPresent()
+                        && (location.get() == StandardLocation.CLASS_PATH
+                                || location.get() == StandardLocation.MODULE_PATH)) {
+                    classpathPaths.addAll(entry.getValue());
+                    if (location.get() == StandardLocation.MODULE_PATH) {
+                        for (Path p : entry.getValue()) {
+                            if (Files.isDirectory(p)) {
+                                reactorPaths.add(p);
+                            }
+                        }
+                    }
+                }
+            }
+        }
+        abiBuild.setClasspathEntries(classpathPaths);
+        if (!reactorPaths.isEmpty()) {
+            abiBuild.setReactorModulePaths(reactorPaths);
+        }
+
+        // Collect annotation processor path for processor classification
+        var processorPaths = new ArrayList<Path>();
+        for (var entry : dependencies.entrySet()) {
+            if (entry.getKey() instanceof JavaPathType type) {
+                var location = type.location();
+                if (location.isPresent()
+                        && (location.get() == StandardLocation.ANNOTATION_PROCESSOR_PATH
+                                || location.get() == StandardLocation.ANNOTATION_PROCESSOR_MODULE_PATH)) {
+                    processorPaths.addAll(entry.getValue());
+                }
+            }
+        }
+        if (!processorPaths.isEmpty()) {
+            abiBuild.setProcessorPath(processorPaths);
+        }
+
+        // Hash module-info-patch.maven files for config change detection
+        abiBuild.setConfigHash(computeConfigHash(configuration));
+
+        // 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).");
+        if (mojo.showCompilationChanges && abiBuild.getRebuildCause() != null) {
+            logger.info("Rebuild cause: " + abiBuild.getRebuildCause());
+            for (Path f : toCompile) {
+                logger.info("  " + f);
+            }
+        }
+
+        var originalSourceFiles = new ArrayList<>(sourceFiles);
+        boolean success = true;
+        // Safety bound: the compile set is monotonically growing (bounded by total source count).
+        // If a bug causes processCompiledClasses to return files already compiled, this prevents
+        // an infinite loop. In practice this limit should never be reached.
+        int maxRounds = originalSourceFiles.size() + 1;
+        int rounds = 0;
+
+        try {
+            while (!toCompile.isEmpty()) {
+                if (++rounds > maxRounds) {
+                    throw new IllegalStateException("ABI cascade loop did not converge after " + maxRounds
+                            + " rounds — " + "possible dependency cycle or bug in processCompiledClasses()");
+                }
+                Set<Path> compileSet = toCompile;
+                sourceFiles = originalSourceFiles.stream()
+                        .filter(sf -> compileSet.contains(sf.file))
+                        .collect(Collectors.toList());
+
+                if (sourceFiles.isEmpty()) {
+                    break;
+                }
+
+                var compilerOutput = new StringWriter();
+                success = compileWithAbiAnalyzer(compiler, configuration, compilerOutput, abiBuild);
+                String output = compilerOutput.toString();
+                if (!output.isBlank()) {
+                    logger.warn(output);
+                }
+                if (!success) {
+                    break;
+                }
+
+                toCompile = abiBuild.processCompiledClasses(compileSet);
+                if (!toCompile.isEmpty()) {
+                    logger.info("ABI cascade: recompiling " + toCompile.size() + " additional file(s).");
+                    if (mojo.showCompilationChanges) {
+                        for (Path f : toCompile) {
+                            logger.info("  " + f);
+                        }
+                    }
+                }
+            }
+        } finally {
+            sourceFiles = originalSourceFiles;
+        }
+
+        if (success) {
+            abiBuild.finish();
+            logger.info("Compiled " + abiBuild.compiledCount() + " file(s), " + abiBuild.unchangedCount()
+                    + " unchanged (ABI strategy).");
+        } else {
+            abiBuild.invalidate();
+            throw new CompilationFailureException("Compilation failed (ABI incremental strategy).");
+        }
+    }
+
+    private String computeConfigHash(Options configuration) {
+        var digest = new StringBuilder();
+
+        // Include compiler options in the config hash so changes to -source/-target/-release
+        // etc. trigger a full rebuild under the ABI strategy.
+        String optionsRepr = String.join("|", configuration.options);
+        digest.append("opts:").append(optionsRepr).append(';');
+
+        for (SourceDirectory source : sourceDirectories) {
+            Path patchFile = source.root.resolve(ModuleInfoPatch.FILENAME);
+            if (Files.isRegularFile(patchFile)) {
+                try {
+                    byte[] content = Files.readAllBytes(patchFile);
+                    digest.append(patchFile)
+                            .append(':')
+                            .append(Sha256.hash(content))
+                            .append(';');
+                } catch (IOException e) {
+                    digest.append(patchFile).append(":unreadable;");
+                }
+            }
+        }
+        if (digest.isEmpty()) {
+            return "";
+        }
+        return Sha256.hash(digest.toString());
+    }
+
+    /**
+     * Compiles sources with the ABI analyzer attached as a TaskListener.

Review Comment:
   💡 **Stale Javadoc.** This still says "Compiles sources with the ABI analyzer attached as a TaskListener" — but the TaskListener approach (`attachTo(JavacTask)`) was removed. The method now just compiles; the bytecode analysis happens in `processCompiledClasses()` after this returns.
   
   ```suggestion
        * Compiles sources for the ABI incremental strategy.
   ```



-- 
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] Add ABI-fingerprint-based incremental compilation strategy [maven-compiler-plugin]

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

   Can we keep this pull request in draft mode? It will be a large effort to review. Some concerns that I had at a first look:
   
   * It seems to do at least two things: 1) scan for dependencies, and 2) check ABI compatibility. Only 1 is necessary for correctness, while 2 is a finer-grain change detection compared to timestamps. However, it is not clear to me that the added cost of ABI analysis is a win compared to the cost of some false positives when using only timestamps.
   * The ABI is formatted as a `String`, then SHA-512 is computed from that `String`. For example, `ACC_PUBLIC` integer value is formatted as the `"public"` string. This adds unnecessary processing compared to computing SHA-512 on a sequence of bytes were binary values are kept binary. Note: this issue disappear if we omit item 2 above.
   * This pull requests duplicates low-level details that could be expressed with higher-level standard API. For example, the `ACC_PUBLIC`, etc. constants defined in `ClassAnalyzer` are already available in `java.lang.reflect.Modifier`.
   * This pull request depends on `jdk.compiler` module. But all modules starting with `jdk.` are not part of the Java specification and are not guaranteed to be available everywhere. For example, I don't know if the Eclipse compiler implements those interfaces.
   * This pull request adds another feature in the mix: detecting the annotation processor type by scanning two non-standard property files. One of them is Gradle-specific, the other one I'm not sure where it come from.
   
   What about the following implementation strategy?
   
   1. In a separated pull request, setup Maven Compiler plugin as a multi-release project using Maven Compiler 4.0.0-beta-5 (I can volunteer for that).
   2. Start with scanning dependencies only. Leave ABI analysis, SHA-512 and detection of annotation processor type to future pull requests.
   3. Use `java.lang.classfile` only. No ASM. It means that dependency tracking would not be available before Java 24, but I think that it is okay. It only means that safe compilation will be slower on those platforms, nothing more.
   4. Instead of doing an analysis during compilation, do the analysis of `.class` files after compilation. It would remove the dependency to the `jdk.compiler` module and would allow incremental compilation to work with any compilers and also in forked mode (not supported by the current pull request).
   


-- 
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] Add ABI-fingerprint-based incremental compilation strategy [maven-compiler-plugin]

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


##########
src/main/java/org/apache/maven/plugin/compiler/incremental/AbiIncrementalBuild.java:
##########
@@ -0,0 +1,898 @@
+/*
+ * 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:
+ *
+ * <pre>{@code
+ * 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();
+ * }</pre>
+ *
+ * <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 = "module:";
+
+    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;
+    /** Source files compiled in previous rounds of the current build (for loop detection). */
+    private Set<String> compiledInPreviousRounds;
+    /** 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/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);
+    }
+
+    /**
+     * 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 {
+        if (compiledInPreviousRounds == null) {
+            compiledInPreviousRounds = new TreeSet<>(allCompiled);
+        }
+        // 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());

Review Comment:
   Stale test comment — removing.



##########
src/main/java/org/apache/maven/plugin/compiler/incremental/AbiIncrementalBuild.java:
##########
@@ -0,0 +1,898 @@
+/*
+ * 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:
+ *
+ * <pre>{@code
+ * 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();
+ * }</pre>
+ *
+ * <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 = "module:";
+
+    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;
+    /** Source files compiled in previous rounds of the current build (for loop detection). */
+    private Set<String> compiledInPreviousRounds;
+    /** Lazily populated on full builds; maps each output class file to its simple top-level class name. */
+    private java.util.Map<Path, String> outputClassIndex;

Review Comment:
   Fixed: `compiledInPreviousRounds` was already removed from the current branch as part of the post-TaskListener refactoring. Confirmed absent in `AbiIncrementalBuild.java`.



##########
src/test/java/org/apache/maven/plugin/compiler/incremental/CompilerTestHelper.java:
##########
@@ -0,0 +1,98 @@
+/*
+ * 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.tools.JavaCompiler;
+import javax.tools.StandardLocation;
+import javax.tools.ToolProvider;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Stream;
+
+/**
+ * Shared helper for incremental compilation tests.
+ */
+class CompilerTestHelper {
+
+    static void writeSource(Path sourceDir, String packageName, String className, String source) throws IOException {
+        Path packageDir = sourceDir.resolve(packageName.replace('.', '/'));
+        Files.createDirectories(packageDir);
+        Files.writeString(packageDir.resolve(className + ".java"), source);
+    }
+
+    /**
+     * Compiles all {@code .java} files under {@code sourceDir} into {@code outputDir}
+     * and returns the output directory. No ABI analysis — raw javac only.
+     */
+    static Map<String, SourceFileAnalysis> compileAndAnalyze(Path sourceDir, Path outputDir) throws IOException {
+        Files.createDirectories(outputDir);
+        List<Path> sourceFiles;
+        try (Stream<Path> walk = Files.walk(sourceDir)) {
+            sourceFiles =
+                    walk.filter(p -> p.toString().endsWith(".java")).sorted().toList();
+        }
+
+        JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
+        try (var fm = compiler.getStandardFileManager(null, null, null)) {
+            fm.setLocation(StandardLocation.CLASS_OUTPUT, List.of(outputDir.toFile()));
+            var units = fm.getJavaFileObjectsFromPaths(sourceFiles);
+            var task = compiler.getTask(null, fm, null, null, null, units);
+
+            if (!task.call()) {
+                throw new RuntimeException("Compilation failed");
+            }
+
+            // Build a minimal SourceFileAnalysis map from bytecode (no dep graph needed here)
+            Map<String, SourceFileAnalysis> results = new java.util.LinkedHashMap<>();
+            for (Path sf : sourceFiles) {
+                try (Stream<Path> walk = Files.walk(outputDir)) {
+                    walk.filter(p -> p.toString().endsWith(".class")).forEach(cf -> {
+                        try {
+                            var analysis = BytecodeAnalyzer.analyze(cf);
+                            var sfa = new SourceFileAnalysis(
+                                    analysis.className(),
+                                    sf.toString(),
+                                    analysis.signatureTypes(),
+                                    analysis.implementationTypes(),
+                                    analysis.abiFingerprint(),
+                                    analysis.abiCanonical(),
+                                    analysis.annotationTypes(),
+                                    analysis.moduleName());
+                            results.put(analysis.className(), sfa);
+                        } catch (IOException e) {
+                            // best effort
+                        }
+                    });
+                } catch (IOException e) {
+                    // best effort
+                }
+            }

Review Comment:
   Fixed in 1ba1359. Removed the outer loop that iterated over source files redundantly. The method now walks the output directory once, keying by class name — eliminating the N×M redundancy and the misleading source attribution.



##########
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,
+     * 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>();
+        var reactorPaths = new LinkedHashSet<Path>();
+        for (var entry : dependencies.entrySet()) {
+            if (entry.getKey() instanceof JavaPathType type) {
+                var location = type.location();
+                if (location.isPresent()
+                        && (location.get() == StandardLocation.CLASS_PATH
+                                || location.get() == StandardLocation.MODULE_PATH)) {
+                    classpathPaths.addAll(entry.getValue());
+                    if (location.get() == StandardLocation.MODULE_PATH) {
+                        for (Path p : entry.getValue()) {
+                            if (Files.isDirectory(p)) {
+                                reactorPaths.add(p);
+                            }
+                        }
+                    }
+                }
+            }
+        }
+        abiBuild.setClasspathEntries(classpathPaths);
+        if (!reactorPaths.isEmpty()) {
+            abiBuild.setReactorModulePaths(reactorPaths);
+        }
+
+        // Collect annotation processor path for processor classification
+        var processorPaths = new ArrayList<Path>();
+        for (var entry : dependencies.entrySet()) {
+            if (entry.getKey() instanceof JavaPathType type) {
+                var location = type.location();
+                if (location.isPresent()
+                        && (location.get() == StandardLocation.ANNOTATION_PROCESSOR_PATH
+                                || location.get() == StandardLocation.ANNOTATION_PROCESSOR_MODULE_PATH)) {
+                    processorPaths.addAll(entry.getValue());
+                }
+            }
+        }
+        if (!processorPaths.isEmpty()) {
+            abiBuild.setProcessorPath(processorPaths);
+        }
+
+        // Hash module-info-patch.maven files for config change detection
+        abiBuild.setConfigHash(computeConfigHash(configuration));
+
+        // 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).");
+        if (mojo.showCompilationChanges && abiBuild.getRebuildCause() != null) {
+            logger.info("Rebuild cause: " + abiBuild.getRebuildCause());
+            for (Path f : toCompile) {
+                logger.info("  " + f);
+            }
+        }
+
+        var originalSourceFiles = new ArrayList<>(sourceFiles);
+        boolean success = true;
+        // Safety bound: the compile set is monotonically growing (bounded by total source count).
+        // If a bug causes processCompiledClasses to return files already compiled, this prevents
+        // an infinite loop. In practice this limit should never be reached.
+        int maxRounds = originalSourceFiles.size() + 1;
+        int rounds = 0;
+
+        try {
+            while (!toCompile.isEmpty()) {
+                if (++rounds > maxRounds) {
+                    throw new IllegalStateException("ABI cascade loop did not converge after " + maxRounds
+                            + " rounds — " + "possible dependency cycle or bug in processCompiledClasses()");
+                }
+                Set<Path> compileSet = toCompile;
+                sourceFiles = originalSourceFiles.stream()
+                        .filter(sf -> compileSet.contains(sf.file))
+                        .collect(Collectors.toList());
+
+                if (sourceFiles.isEmpty()) {
+                    break;
+                }
+
+                var compilerOutput = new StringWriter();
+                success = compileWithAbiAnalyzer(compiler, configuration, compilerOutput, abiBuild);
+                String output = compilerOutput.toString();
+                if (!output.isBlank()) {
+                    logger.warn(output);
+                }
+                if (!success) {
+                    break;
+                }
+
+                toCompile = abiBuild.processCompiledClasses(compileSet);
+                if (!toCompile.isEmpty()) {
+                    logger.info("ABI cascade: recompiling " + toCompile.size() + " additional file(s).");
+                    if (mojo.showCompilationChanges) {
+                        for (Path f : toCompile) {
+                            logger.info("  " + f);
+                        }
+                    }
+                }
+            }
+        } finally {
+            sourceFiles = originalSourceFiles;
+        }
+
+        if (success) {
+            abiBuild.finish();
+            logger.info("Compiled " + abiBuild.compiledCount() + " file(s), " + abiBuild.unchangedCount()
+                    + " unchanged (ABI strategy).");
+        } else {
+            abiBuild.invalidate();
+            throw new CompilationFailureException("Compilation failed (ABI incremental strategy).");
+        }
+    }
+
+    private String computeConfigHash(Options configuration) {
+        var digest = new StringBuilder();
+
+        // Include compiler options in the config hash so changes to -source/-target/-release
+        // etc. trigger a full rebuild under the ABI strategy.
+        String optionsRepr = String.join("|", configuration.options);
+        digest.append("opts:").append(optionsRepr).append(';');
+
+        for (SourceDirectory source : sourceDirectories) {
+            Path patchFile = source.root.resolve(ModuleInfoPatch.FILENAME);
+            if (Files.isRegularFile(patchFile)) {
+                try {
+                    byte[] content = Files.readAllBytes(patchFile);
+                    digest.append(patchFile)
+                            .append(':')
+                            .append(Sha256.hash(content))
+                            .append(';');
+                } catch (IOException e) {
+                    digest.append(patchFile).append(":unreadable;");
+                }
+            }
+        }
+        if (digest.isEmpty()) {
+            return "";
+        }
+        return Sha256.hash(digest.toString());
+    }
+
+    /**
+     * Compiles sources with the ABI analyzer attached as a TaskListener.

Review Comment:
   Fixed in 1ba1359. Javadoc now reads: "Compiles sources and performs ABI analysis on the compiled classes."



##########
src/main/java24/org/apache/maven/plugin/compiler/incremental/ClassfileClassAnalyzer.java:
##########
@@ -0,0 +1,571 @@
+/*
+ * 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.lang.classfile.Attributes;
+import java.lang.classfile.ClassFile;
+import java.lang.classfile.ClassModel;
+import java.lang.classfile.FieldModel;
+import java.lang.classfile.MethodModel;
+import java.lang.classfile.attribute.ModuleAttribute;
+import java.lang.classfile.attribute.ModuleProvideInfo;
+import java.lang.classfile.attribute.ModuleRequireInfo;
+import java.lang.classfile.instruction.FieldInstruction;
+import java.lang.classfile.instruction.InvokeInstruction;
+import java.lang.classfile.instruction.NewMultiArrayInstruction;
+import java.lang.classfile.instruction.NewObjectInstruction;
+import java.lang.classfile.instruction.TypeCheckInstruction;
+import java.lang.reflect.AccessFlag;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.TreeSet;
+
+/**
+ * {@link ClassAnalyzer} implementation using the standard {@code java.lang.classfile}
+ * API, available since JDK 24.
+ *
+ * <p>This implementation is loaded reflectively by {@link BytecodeAnalyzer} when
+ * the running JVM version is 24 or later. It is compiled separately with
+ * {@code --release 24} to avoid a compile-time dependency on the classfile API
+ * in the main sources.
+ *
+ * <p>Type references are classified into two sets:
+ * <ul>
+ *   <li><b>signatureTypes</b> — types from the public API surface: supertype, interfaces,
+ *       field/method descriptors of non-private members, exception types, annotations.
+ *       These are what downstream consumers structurally depend on.</li>
+ *   <li><b>implementationTypes</b> — types referenced only in method body bytecode
+ *       instructions (INVOKE*, field access, NEW, CHECKCAST, etc.), and descriptor
+ *       types of private members. Changes to these do not cascade to the class's
+ *       signature consumers.</li>
+ * </ul>
+ *
+ * <p>{@code module-info.class} is handled specially: its ABI fingerprint is derived
+ * from the {@code Module} attribute directives (requires, exports, opens, uses,
+ * provides), and no implementation types are collected.
+ *
+ * @see BytecodeAnalyzer
+ * @see ClassAnalyzer
+ */
+class ClassfileClassAnalyzer extends ClassAnalyzer {
+
+    @Override
+    public BytecodeAnalyzer.ClassAnalysis analyze(byte[] classBytes) {
+        ClassModel cm = ClassFile.of().parse(classBytes);
+
+        // module-info.class has the MODULE access flag
+        if (cm.flags().has(AccessFlag.MODULE)) {
+            return analyzeModuleInfo(cm);
+        }
+
+        String className = BytecodeAnalyzer.toJavaName(cm.thisClass().asInternalName());
+        Set<String> signatureTypes = new TreeSet<>();
+        Set<String> implementationTypes = new TreeSet<>();
+        Set<String> annotationTypes = new TreeSet<>();
+
+        collectTypes(cm, signatureTypes, implementationTypes, annotationTypes);
+
+        // Remove self-references and JDK types
+        signatureTypes.remove(className);
+        implementationTypes.remove(className);
+        implementationTypes.removeAll(signatureTypes); // sig takes precedence
+        signatureTypes.removeIf(ClassfileClassAnalyzer::isJdkType);
+        implementationTypes.removeIf(ClassfileClassAnalyzer::isJdkType);
+        annotationTypes.removeIf(ClassfileClassAnalyzer::isJdkType);
+
+        String abiCanonical = buildCanonical(cm);
+        String abiFingerprint = Sha256.hash(abiCanonical);
+
+        // Read the SourceFile attribute for accurate source-file attribution
+        String sourceFileName = cm.findAttribute(Attributes.sourceFile())
+                .map(sf -> sf.sourceFile().stringValue())
+                .orElse("");
+
+        return new BytecodeAnalyzer.ClassAnalysis(
+                className,
+                abiFingerprint,
+                abiCanonical,
+                signatureTypes,
+                implementationTypes,
+                annotationTypes,
+                /* moduleName= */ "",
+                /* isModuleInfo= */ false,
+                sourceFileName);
+    }
+
+    // --- module-info handling ---
+
+    private static BytecodeAnalyzer.ClassAnalysis analyzeModuleInfo(ClassModel cm) {
+        var moduleAttrOpt = cm.findAttribute(Attributes.module());
+        if (moduleAttrOpt.isEmpty()) {
+            return new BytecodeAnalyzer.ClassAnalysis(
+                    "module-info", "", "", Set.of(), Set.of(), Set.of(), "", true, "module-info.java");
+        }
+
+        ModuleAttribute mod = moduleAttrOpt.get();
+        String moduleName = mod.moduleName().name().stringValue();
+        boolean isOpen = cm.flags().has(AccessFlag.OPEN);
+
+        var requires = new TreeSet<String>();
+        var exports = new TreeSet<String>();
+        var opens = new TreeSet<String>();
+        var uses = new TreeSet<String>();
+        var provides = new TreeSet<String>();
+
+        for (ModuleRequireInfo req : mod.requires()) {
+            var sb = new StringBuilder("requires ");
+            if (req.requiresFlags().contains(AccessFlag.TRANSITIVE)) sb.append("transitive ");
+            if (req.requiresFlags().contains(AccessFlag.STATIC_PHASE)) sb.append("static ");
+            sb.append(req.requires().name().stringValue());
+            requires.add(sb.toString());
+        }
+        for (var exp : mod.exports()) {
+            var sb = new StringBuilder("exports ")
+                    .append(exp.exportedPackage().name().stringValue().replace('/', '.'));
+            var tos = exp.exportsTo();
+            if (!tos.isEmpty()) {
+                sb.append(" to ")
+                        .append(tos.stream()
+                                .map(e -> e.name().stringValue())
+                                .sorted()
+                                .reduce((a, b) -> a + ", " + b)
+                                .orElse(""));
+            }
+            exports.add(sb.toString());
+        }
+        for (var op : mod.opens()) {
+            var sb = new StringBuilder("opens ")
+                    .append(op.openedPackage().name().stringValue().replace('/', '.'));
+            var tos = op.opensTo();
+            if (!tos.isEmpty()) {
+                sb.append(" to ")
+                        .append(tos.stream()
+                                .map(e -> e.name().stringValue())
+                                .sorted()
+                                .reduce((a, b) -> a + ", " + b)
+                                .orElse(""));
+            }
+            opens.add(sb.toString());
+        }
+        for (var u : mod.uses()) {
+            uses.add("uses " + BytecodeAnalyzer.toJavaName(u.asInternalName()));
+        }
+        for (ModuleProvideInfo p : mod.provides()) {
+            var sb = new StringBuilder("provides ")
+                    .append(BytecodeAnalyzer.toJavaName(p.provides().asInternalName()));
+            var impls = p.providesWith();
+            if (!impls.isEmpty()) {
+                sb.append(" with ")
+                        .append(impls.stream()
+                                .map(i -> BytecodeAnalyzer.toJavaName(i.asInternalName()))
+                                .sorted()
+                                .reduce((a, b) -> a + ", " + b)
+                                .orElse(""));
+            }
+            provides.add(sb.toString());
+        }
+
+        var canonical = new StringBuilder();
+        if (isOpen) canonical.append("open ");
+        canonical.append("module ").append(moduleName).append('\n');
+        for (String r : requires) canonical.append("  ").append(r).append('\n');
+        for (String e : exports) canonical.append("  ").append(e).append('\n');
+        for (String o : opens) canonical.append("  ").append(o).append('\n');
+        for (String u : uses) canonical.append("  ").append(u).append('\n');
+        for (String p : provides) canonical.append("  ").append(p).append('\n');
+
+        String abiCanonical = canonical.toString();
+        String abiFingerprint = Sha256.hash(abiCanonical);
+
+        // Signature deps for module-info: service types from uses/provides
+        var sigTypes = new TreeSet<String>();
+        for (var u : mod.uses()) {
+            String name = BytecodeAnalyzer.toJavaName(u.asInternalName());
+            if (!isJdkType(name)) sigTypes.add(name);
+        }
+        for (ModuleProvideInfo p : mod.provides()) {
+            String svc = BytecodeAnalyzer.toJavaName(p.provides().asInternalName());
+            if (!isJdkType(svc)) sigTypes.add(svc);
+            for (var impl : p.providesWith()) {
+                String implName = BytecodeAnalyzer.toJavaName(impl.asInternalName());
+                if (!isJdkType(implName)) sigTypes.add(implName);
+            }
+        }
+
+        // module-info qualified name uses the MODULE_PREFIX convention
+        String qualifiedName = AbiIncrementalBuild.MODULE_PREFIX + moduleName;
+
+        return new BytecodeAnalyzer.ClassAnalysis(
+                qualifiedName,
+                abiFingerprint,
+                abiCanonical,
+                sigTypes,
+                Set.of(),
+                Set.of(),
+                moduleName,
+                /* isModuleInfo= */ true,
+                /* sourceFileName= */ "module-info.java");
+    }
+
+    // --- type reference collection ---
+
+    private static void collectTypes(
+            ClassModel cm, Set<String> signatureTypes, Set<String> implementationTypes, Set<String> annotationTypes) {
+
+        // Superclass and interfaces are always signature-level
+        cm.superclass().ifPresent(sup -> addRef(sup.asInternalName(), signatureTypes));
+        for (var iface : cm.interfaces()) {
+            addRef(iface.asInternalName(), signatureTypes);
+        }
+
+        // Class generic signature (e.g. "class Foo<T extends Bar>") — extract type args
+        cm.findAttribute(Attributes.signature())
+                .ifPresent(sig -> addGenericSignatureRefs(sig.signature().stringValue(), signatureTypes));
+
+        // Class-level annotations → signature + annotation tracking
+        collectAnnotations(
+                cm.findAttribute(Attributes.runtimeVisibleAnnotations())
+                        .map(a -> a.annotations())
+                        .orElse(List.of()),
+                signatureTypes,
+                annotationTypes);
+        collectAnnotations(
+                cm.findAttribute(Attributes.runtimeInvisibleAnnotations())
+                        .map(a -> a.annotations())
+                        .orElse(List.of()),
+                signatureTypes,
+                annotationTypes);
+
+        for (FieldModel field : cm.fields()) {
+            boolean isPrivate = field.flags().has(AccessFlag.PRIVATE);
+            Set<String> descTarget = isPrivate ? implementationTypes : signatureTypes;
+
+            addDescriptor(field.fieldType().stringValue(), descTarget);
+
+            // Field generic signature — extract concrete type arguments (e.g. Foo in List<Foo>)
+            field.findAttribute(Attributes.signature())
+                    .ifPresent(sig -> addGenericSignatureRefs(sig.signature().stringValue(), descTarget));
+
+            // Field annotations go to the same target as the field descriptor
+            collectAnnotations(
+                    field.findAttribute(Attributes.runtimeVisibleAnnotations())
+                            .map(a -> a.annotations())
+                            .orElse(List.of()),
+                    descTarget,
+                    annotationTypes);
+            collectAnnotations(
+                    field.findAttribute(Attributes.runtimeInvisibleAnnotations())
+                            .map(a -> a.annotations())
+                            .orElse(List.of()),
+                    descTarget,
+                    annotationTypes);
+        }
+
+        for (MethodModel method : cm.methods()) {
+            boolean isPrivate = method.flags().has(AccessFlag.PRIVATE);
+            Set<String> sigTarget = isPrivate ? implementationTypes : signatureTypes;
+
+            // Method descriptor types (params + return) and exceptions → sig if non-private
+            addMethodDescriptor(method.methodType().stringValue(), sigTarget);
+            method.findAttribute(Attributes.exceptions())
+                    .ifPresent(ex -> ex.exceptions().forEach(e -> addRef(e.asInternalName(), sigTarget)));
+
+            // Method generic signature — extract concrete type arguments (e.g. Foo in List<Foo>)
+            method.findAttribute(Attributes.signature())
+                    .ifPresent(sig -> addGenericSignatureRefs(sig.signature().stringValue(), sigTarget));
+
+            // Method annotations → same target as descriptor
+            collectAnnotations(
+                    method.findAttribute(Attributes.runtimeVisibleAnnotations())
+                            .map(a -> a.annotations())
+                            .orElse(List.of()),
+                    sigTarget,
+                    annotationTypes);
+            collectAnnotations(
+                    method.findAttribute(Attributes.runtimeInvisibleAnnotations())
+                            .map(a -> a.annotations())
+                            .orElse(List.of()),
+                    sigTarget,
+                    annotationTypes);
+
+            // Method body instructions → always implementation-level
+            method.code().ifPresent(code -> {
+                for (var element : code) {
+                    switch (element) {
+                        case InvokeInstruction ii -> addRef(ii.owner().asInternalName(), implementationTypes);
+                        case FieldInstruction fi -> addRef(fi.owner().asInternalName(), implementationTypes);
+                        case TypeCheckInstruction tci -> addRef(tci.type().asInternalName(), implementationTypes);
+                        case NewObjectInstruction noi -> addRef(noi.className().asInternalName(), implementationTypes);
+                        case NewMultiArrayInstruction nma ->
+                            addRef(nma.arrayType().asInternalName(), implementationTypes);
+                        default -> {}
+                    }
+                }
+            });
+        }
+
+        // Scan the constant pool for class references that are not captured by instructions.
+        // This covers compile-time constants (static final primitives) whose values are inlined
+        // by javac — the referencing class has no GETSTATIC instruction, but the resolved class
+        // is still present in the constant pool.
+        for (var entry : cm.constantPool()) {
+            if (entry instanceof java.lang.classfile.constantpool.ClassEntry ce) {
+                String internalName = ce.asInternalName();
+                // Skip array type descriptors and the class itself
+                if (!internalName.startsWith("[")
+                        && !internalName.equals(cm.thisClass().asInternalName())) {
+                    addRef(internalName, implementationTypes);
+                }
+            }
+        }
+    }
+
+    private static void collectAnnotations(
+            java.util.List<? extends java.lang.classfile.Annotation> annotations,
+            Set<String> typeTarget,
+            Set<String> annotationTarget) {
+        for (var ann : annotations) {
+            String descriptor = ann.className().stringValue();
+            String name = descriptorToJavaName(descriptor);
+            if (name != null) {
+                typeTarget.add(name);
+                annotationTarget.add(name);
+            }
+        }
+    }
+
+    // --- ABI canonical form ---
+
+    private static String buildCanonical(ClassModel cm) {
+        var fields = new ArrayList<FieldInfo>();
+        var methods = new ArrayList<MethodInfo>();
+
+        for (FieldModel field : cm.fields()) {
+            int access = accessMask(field.flags().flags());
+            if (!isPrivateOrSynthetic(access)) {
+                Object constantValue = field.findAttribute(Attributes.constantValue())
+                        .map(cv -> cv.constant().constantValue())
+                        .orElse(null);
+                String fieldSig = field.findAttribute(Attributes.signature())
+                        .map(s -> s.signature().stringValue())
+                        .orElse(null);
+                fields.add(new FieldInfo(
+                        access,
+                        field.fieldName().stringValue(),
+                        field.fieldType().stringValue(),
+                        constantValue,
+                        fieldSig));
+            }
+        }
+
+        for (MethodModel method : cm.methods()) {
+            int access = accessMask(method.flags().flags());
+            String name = method.methodName().stringValue();
+            if (!isPrivateOrSynthetic(access) && !"<clinit>".equals(name)) {
+                String methodSig = method.findAttribute(Attributes.signature())
+                        .map(s -> s.signature().stringValue())
+                        .orElse(null);
+                methods.add(new MethodInfo(access, name, method.methodType().stringValue(), methodSig));
+            }
+        }
+
+        int classAccess = accessMask(cm.flags().flags());
+        String className = BytecodeAnalyzer.toJavaName(cm.thisClass().asInternalName());
+        String classSignature = cm.findAttribute(Attributes.signature())
+                .map(s -> s.signature().stringValue())
+                .orElse(null);
+        String superName = cm.superclass()
+                .map(sup -> BytecodeAnalyzer.toJavaName(sup.asInternalName()))
+                .orElse(null);
+        List<String> ifaceNames = cm.interfaces().stream()
+                .map(iface -> BytecodeAnalyzer.toJavaName(iface.asInternalName()))
+                .toList();
+
+        return buildCanonicalForm(classAccess, className, classSignature, superName, ifaceNames, fields, methods);
+    }
+
+    // --- generic signature type reference extraction ---
+
+    /**
+     * Extracts all concrete class type references from a JVM generic signature string
+     * (JVMS §4.7.9.1) and adds them to {@code types}.
+     *
+     * <p>Examples (signature → extracted types):
+     * <ul>
+     *   <li>{@code Ljava/util/List<Lcom/example/Foo;>;} → {@code com.example.Foo}
+     *   <li>{@code Ljava/util/Map<Lcom/example/Key;Lcom/example/Val;>;} → {@code com.example.Key}, {@code com.example.Val}
+     *   <li>{@code (Lcom/example/Req;)Lcom/example/Resp;} → {@code com.example.Req}, {@code com.example.Resp}
+     *   <li>{@code TT;} (type variable) → nothing
+     *   <li>{@code +Lcom/Foo;} (wildcard) → {@code com.Foo}
+     * </ul>
+     *
+     * <p>Type variables ({@code TName;}) and primitive types are intentionally skipped —
+     * they are not concrete dependencies.
+     */
+    private static void addGenericSignatureRefs(String sig, Set<String> types) {
+        if (sig == null || sig.isEmpty()) {
+            return;
+        }
+        int[] pos = {0};
+        parseSig(sig, pos, types);
+    }
+
+    /**
+     * Recursive descent parser for JVM generic signatures.
+     * Advances {@code pos[0]} as it consumes characters.
+     */
+    private static void parseSig(String sig, int[] pos, Set<String> types) {
+        while (pos[0] < sig.length()) {
+            char c = sig.charAt(pos[0]);
+            switch (c) {
+                case 'L' -> parseClassTypeSignature(sig, pos, types);
+                case 'T' -> {
+                    // TypeVariableSignature: T<Identifier>; — skip, it's a generic parameter, not a concrete type
+                    pos[0]++; // skip 'T'
+                    while (pos[0] < sig.length() && sig.charAt(pos[0]) != ';') {
+                        pos[0]++;
+                    }
+                    if (pos[0] < sig.length()) pos[0]++; // skip ';'
+                }

Review Comment:
   Already fixed in e474b58. The `parseSig()` FTP mode now skips the identifier by scanning to `:` or `>` (not `;`), so 'T'-prefixed type param names no longer swallow the bound. Verified by `formalTypeParameterBoundsTracked` and `multipleTypeParameterBoundsAllTracked` unit tests.



-- 
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] Add ABI-fingerprint-based incremental compilation strategy [maven-compiler-plugin]

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


##########
pom.xml:
##########
@@ -192,6 +192,34 @@ under the License.
         </plugin>
       </plugins>
     </pluginManagement>
+    <plugins>
+      <plugin>
+        <groupId>org.apache.maven.plugins</groupId>
+        <artifactId>maven-compiler-plugin</artifactId>
+        <executions>
+          <execution>
+            <!--
+              Compile sources that require java.lang.classfile (JDK 24+) separately
+              with release=24, so the main sources stay at the baseline Java version.
+              The compiled classes land alongside the main classes in the output directory.
+              BytecodeAnalyzer loads ClassfileClassAnalyzer reflectively at runtime when
+              the running JVM is JDK 24 or later; AsmClassAnalyzer is used otherwise.
+            -->
+            <id>compile-java24</id>
+            <goals>
+              <goal>compile</goal>
+            </goals>
+            <phase>compile</phase>
+            <configuration>
+              <compileSourceRoots>
+                <compileSourceRoot>${project.basedir}/src/main/java24</compileSourceRoot>
+              </compileSourceRoots>
+              <release>24</release>

Review Comment:
   🔴 **Critical — Build broken on JDK < 24.** The `compile-java24` execution is in the main `<build><plugins>` section, not inside a profile. On JDK 17 or 21, `javac` does not support `--release 24` (it only accepts release values up to the running JDK version), so this execution will fail with:
   
   ```
   error: release version 24 not supported
   ```
   
   This breaks CI (the shared `maven-verify.yml` workflow tests on JDK 17, 21, 24, 25) and contributor builds on any JDK below 24.
   
   Fix: wrap the execution in a JDK 24+ activation profile:
   
   ```suggestion
                 <release>24</release>
               </configuration>
             </execution>
           </executions>
         </plugin>
       </plugins>
     </build>
   
     <profiles>
       <profile>
         <id>java24+</id>
         <activation>
           <jdk>[24,)</jdk>
         </activation>
         <build>
           <plugins>
             <plugin>
               <groupId>org.apache.maven.plugins</groupId>
               <artifactId>maven-compiler-plugin</artifactId>
               <executions>
                 <execution>
                   <id>compile-java24</id>
                   <goals>
                     <goal>compile</goal>
                   </goals>
                   <phase>compile</phase>
                   <configuration>
                     <compileSourceRoots>
                       <compileSourceRoot>${project.basedir}/src/main/java24</compileSourceRoot>
                     </compileSourceRoots>
                     <release>24</release>
                   </configuration>
                 </execution>
               </executions>
             </plugin>
           </plugins>
         </build>
       </profile>
   ```
   
   Note: This means `ClassfileClassAnalyzer.class` won't be in the JAR when built on JDK < 24, but that's fine — the reflective loading in `selectAnalyzer()` handles this gracefully (catches `Exception | LinkageError` and falls back to ASM). The class will be included in the release JAR when built on JDK 24+.



##########
src/test/java/org/apache/maven/plugin/compiler/incremental/BytecodeAnalyzerTest.java:
##########
@@ -0,0 +1,180 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.plugin.compiler.incremental;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class BytecodeAnalyzerTest {
+
+    @TempDir
+    Path sourceDir;
+
+    @TempDir
+    Path outputDir;
+
+    @Test
+    void descriptorToReadableVoid() {
+        assertEquals("void", BytecodeAnalyzer.descriptorToReadable("V"));
+    }
+
+    @Test
+    void descriptorToReadableBoolean() {
+        assertEquals("boolean", BytecodeAnalyzer.descriptorToReadable("Z"));
+    }
+
+    @Test
+    void descriptorToReadableByte() {
+        assertEquals("byte", BytecodeAnalyzer.descriptorToReadable("B"));
+    }
+
+    @Test
+    void descriptorToReadableChar() {
+        assertEquals("char", BytecodeAnalyzer.descriptorToReadable("C"));
+    }
+
+    @Test
+    void descriptorToReadableShort() {
+        assertEquals("short", BytecodeAnalyzer.descriptorToReadable("S"));
+    }
+
+    @Test
+    void descriptorToReadableInt() {
+        assertEquals("int", BytecodeAnalyzer.descriptorToReadable("I"));
+    }
+
+    @Test
+    void descriptorToReadableLong() {
+        assertEquals("long", BytecodeAnalyzer.descriptorToReadable("J"));
+    }
+
+    @Test
+    void descriptorToReadableFloat() {
+        assertEquals("float", BytecodeAnalyzer.descriptorToReadable("F"));
+    }
+
+    @Test
+    void descriptorToReadableDouble() {
+        assertEquals("double", BytecodeAnalyzer.descriptorToReadable("D"));
+    }
+
+    @Test
+    void descriptorToReadableObject() {
+        assertEquals("java.lang.String", BytecodeAnalyzer.descriptorToReadable("Ljava/lang/String;"));
+    }
+
+    @Test
+    void descriptorToReadableArray() {
+        assertEquals("int[]", BytecodeAnalyzer.descriptorToReadable("[I"));
+        assertEquals("java.lang.String[]", BytecodeAnalyzer.descriptorToReadable("[Ljava/lang/String;"));
+    }
+
+    @Test
+    void descriptorToReadableEmpty() {
+        assertEquals("", BytecodeAnalyzer.descriptorToReadable(""));
+    }
+
+    @Test
+    void parseParamsMultiple() {
+        assertEquals("java.lang.String, int", BytecodeAnalyzer.parseParams("(Ljava/lang/String;I)V"));
+    }
+
+    @Test
+    void parseParamsEmpty() {
+        assertEquals("", BytecodeAnalyzer.parseParams("()V"));
+    }
+
+    @Test
+    void parseReturnType() {
+        assertEquals("java.lang.String", BytecodeAnalyzer.parseReturn("(I)Ljava/lang/String;"));
+        assertEquals("void", BytecodeAnalyzer.parseReturn("()V"));
+        assertEquals("int", BytecodeAnalyzer.parseReturn("()I"));
+    }
+
+    @Test
+    void analyzePathProducesCorrectClassName() throws Exception {
+        CompilerTestHelper.writeSource(
+                sourceDir,
+                "test",
+                "Hello",
+                "package test; public class Hello { public String greet() { return \"hi\"; } }");
+        CompilerTestHelper.compileAndAnalyze(sourceDir, outputDir);
+
+        Path classFile = outputDir.resolve("test/Hello.class");
+        assertTrue(Files.exists(classFile));
+
+        var analysis = BytecodeAnalyzer.analyze(classFile);
+        assertEquals("test.Hello", analysis.className());
+        assertNotNull(analysis.abiFingerprint());
+        assertNotNull(analysis.abiCanonical());
+    }
+
+    @Test
+    void analyzeBytesEquivalentToAnalyzePath() throws Exception {
+        CompilerTestHelper.writeSource(
+                sourceDir, "test", "Hello", "package test; public class Hello { public int value() { return 42; } }");
+        CompilerTestHelper.compileAndAnalyze(sourceDir, outputDir);
+
+        Path classFile = outputDir.resolve("test/Hello.class");
+        var fromPath = BytecodeAnalyzer.analyze(classFile);
+        var fromBytes = BytecodeAnalyzer.analyze(Files.readAllBytes(classFile));
+
+        assertEquals(fromPath.className(), fromBytes.className());
+        assertEquals(fromPath.abiFingerprint(), fromBytes.abiFingerprint());
+        assertEquals(fromPath.abiCanonical(), fromBytes.abiCanonical());
+        assertEquals(fromPath.referencedTypes(), fromBytes.referencedTypes());
+    }
+
+    /**
+     * When running on JDK 24+, {@link BytecodeAnalyzer} uses {@link ClassfileClassAnalyzer}
+     * instead of {@link AsmClassAnalyzer}. Both must produce identical results for the same
+     * class file. This test cross-validates the two implementations against each other.
+     */
+    @Test
+    void asmAndClassfileAnalyzersProduceIdenticalResults() throws Exception {
+        CompilerTestHelper.writeSource(sourceDir, "test", "Subject", """
+                package test;
+                public class Subject implements Runnable {
+                    public static final String CONST = "hello";
+                    private int secret = 42;
+                    public String greet(int x, java.util.List<String> items) { return CONST + x; }
+                    @Override public void run() { greet(1, null); }
+                    protected static int helper(byte b) throws java.io.IOException { return b; }
+                }
+                """);
+        CompilerTestHelper.compileAndAnalyze(sourceDir, outputDir);
+
+        byte[] bytes = Files.readAllBytes(outputDir.resolve("test/Subject.class"));
+
+        var asmResult = new AsmClassAnalyzer().analyze(bytes);
+        var cfResult = new ClassfileClassAnalyzer().analyze(bytes);

Review Comment:
   🔴 **Critical — Test compilation broken on JDK < 24.** This test directly instantiates `ClassfileClassAnalyzer`, which is compiled from `src/main/java24/` with `--release 24`. On JDK < 24:
   
   1. The `compile-java24` execution fails (see pom.xml comment), so `ClassfileClassAnalyzer.class` doesn't exist
   2. This test source references that class by name → compilation error
   
   Fix: make the test conditional on JDK 24+ using JUnit 5's `@EnabledForJreRange`:
   
   ```suggestion
           var asmResult = new AsmClassAnalyzer().analyze(bytes);
   ```
   
   Actually, the entire test method needs to be guarded:
   
   ```java
   @Test
   @org.junit.jupiter.api.condition.EnabledForJreRange(
       min = org.junit.jupiter.api.condition.JRE.JAVA_24)
   void asmAndClassfileAnalyzersProduceIdenticalResults() throws Exception {
   ```
   
   But even with the annotation, the class reference in the method body will fail compilation on JDK < 24. The cross-validation test needs to use **reflection** to instantiate `ClassfileClassAnalyzer` (same pattern as `BytecodeAnalyzer.selectAnalyzer()`), or be moved to a separate test source root compiled with `--release 24` (e.g. `src/test/java24/`).



-- 
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] Add ABI-fingerprint-based incremental compilation strategy [maven-compiler-plugin]

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

   @gnodet on my side i would be ok to only support it for java 24+ and ignore asm, it is the way to go and agree 2 impl are not worth it (but asm costs more than it helps _today_ IMHO).
   
   On ABI I agree with the downstream support but somehow we should have the module support without relying on git. While ABI is 100% for upstream/downstream deps it is awesome but means we have 2 incremental mechanism, I would hope a centralised state with potentially both info (ABI and module fingerprints) to avoid to spread it


-- 
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] Add ABI-fingerprint-based incremental compilation strategy [maven-compiler-plugin]

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


##########
src/test/java/org/apache/maven/plugin/compiler/incremental/BytecodeAnalyzerTest.java:
##########
@@ -0,0 +1,180 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.plugin.compiler.incremental;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class BytecodeAnalyzerTest {
+
+    @TempDir
+    Path sourceDir;
+
+    @TempDir
+    Path outputDir;
+
+    @Test
+    void descriptorToReadableVoid() {
+        assertEquals("void", BytecodeAnalyzer.descriptorToReadable("V"));
+    }
+
+    @Test
+    void descriptorToReadableBoolean() {
+        assertEquals("boolean", BytecodeAnalyzer.descriptorToReadable("Z"));
+    }
+
+    @Test
+    void descriptorToReadableByte() {
+        assertEquals("byte", BytecodeAnalyzer.descriptorToReadable("B"));
+    }
+
+    @Test
+    void descriptorToReadableChar() {
+        assertEquals("char", BytecodeAnalyzer.descriptorToReadable("C"));
+    }
+
+    @Test
+    void descriptorToReadableShort() {
+        assertEquals("short", BytecodeAnalyzer.descriptorToReadable("S"));
+    }
+
+    @Test
+    void descriptorToReadableInt() {
+        assertEquals("int", BytecodeAnalyzer.descriptorToReadable("I"));
+    }
+
+    @Test
+    void descriptorToReadableLong() {
+        assertEquals("long", BytecodeAnalyzer.descriptorToReadable("J"));
+    }
+
+    @Test
+    void descriptorToReadableFloat() {
+        assertEquals("float", BytecodeAnalyzer.descriptorToReadable("F"));
+    }
+
+    @Test
+    void descriptorToReadableDouble() {
+        assertEquals("double", BytecodeAnalyzer.descriptorToReadable("D"));
+    }
+
+    @Test
+    void descriptorToReadableObject() {
+        assertEquals("java.lang.String", BytecodeAnalyzer.descriptorToReadable("Ljava/lang/String;"));
+    }
+
+    @Test
+    void descriptorToReadableArray() {
+        assertEquals("int[]", BytecodeAnalyzer.descriptorToReadable("[I"));
+        assertEquals("java.lang.String[]", BytecodeAnalyzer.descriptorToReadable("[Ljava/lang/String;"));
+    }
+
+    @Test
+    void descriptorToReadableEmpty() {
+        assertEquals("", BytecodeAnalyzer.descriptorToReadable(""));
+    }
+
+    @Test
+    void parseParamsMultiple() {
+        assertEquals("java.lang.String, int", BytecodeAnalyzer.parseParams("(Ljava/lang/String;I)V"));
+    }
+
+    @Test
+    void parseParamsEmpty() {
+        assertEquals("", BytecodeAnalyzer.parseParams("()V"));
+    }
+
+    @Test
+    void parseReturnType() {
+        assertEquals("java.lang.String", BytecodeAnalyzer.parseReturn("(I)Ljava/lang/String;"));
+        assertEquals("void", BytecodeAnalyzer.parseReturn("()V"));
+        assertEquals("int", BytecodeAnalyzer.parseReturn("()I"));
+    }
+
+    @Test
+    void analyzePathProducesCorrectClassName() throws Exception {
+        CompilerTestHelper.writeSource(
+                sourceDir,
+                "test",
+                "Hello",
+                "package test; public class Hello { public String greet() { return \"hi\"; } }");
+        CompilerTestHelper.compileAndAnalyze(sourceDir, outputDir);
+
+        Path classFile = outputDir.resolve("test/Hello.class");
+        assertTrue(Files.exists(classFile));
+
+        var analysis = BytecodeAnalyzer.analyze(classFile);
+        assertEquals("test.Hello", analysis.className());
+        assertNotNull(analysis.abiFingerprint());
+        assertNotNull(analysis.abiCanonical());
+    }
+
+    @Test
+    void analyzeBytesEquivalentToAnalyzePath() throws Exception {
+        CompilerTestHelper.writeSource(
+                sourceDir, "test", "Hello", "package test; public class Hello { public int value() { return 42; } }");
+        CompilerTestHelper.compileAndAnalyze(sourceDir, outputDir);
+
+        Path classFile = outputDir.resolve("test/Hello.class");
+        var fromPath = BytecodeAnalyzer.analyze(classFile);
+        var fromBytes = BytecodeAnalyzer.analyze(Files.readAllBytes(classFile));
+
+        assertEquals(fromPath.className(), fromBytes.className());
+        assertEquals(fromPath.abiFingerprint(), fromBytes.abiFingerprint());
+        assertEquals(fromPath.abiCanonical(), fromBytes.abiCanonical());
+        assertEquals(fromPath.referencedTypes(), fromBytes.referencedTypes());
+    }
+
+    /**
+     * When running on JDK 24+, {@link BytecodeAnalyzer} uses {@link ClassfileClassAnalyzer}
+     * instead of {@link AsmClassAnalyzer}. Both must produce identical results for the same
+     * class file. This test cross-validates the two implementations against each other.
+     */
+    @Test
+    void asmAndClassfileAnalyzersProduceIdenticalResults() throws Exception {
+        CompilerTestHelper.writeSource(sourceDir, "test", "Subject", """
+                package test;
+                public class Subject implements Runnable {
+                    public static final String CONST = "hello";
+                    private int secret = 42;
+                    public String greet(int x, java.util.List<String> items) { return CONST + x; }
+                    @Override public void run() { greet(1, null); }
+                    protected static int helper(byte b) throws java.io.IOException { return b; }
+                }
+                """);
+        CompilerTestHelper.compileAndAnalyze(sourceDir, outputDir);
+
+        byte[] bytes = Files.readAllBytes(outputDir.resolve("test/Subject.class"));
+
+        var asmResult = new AsmClassAnalyzer().analyze(bytes);
+        var cfResult = new ClassfileClassAnalyzer().analyze(bytes);

Review Comment:
   Targeting Java 17 at runtime does not mean that the plugin must be buildable on Java 17. Wouldn't it be okay to require Java 24 for building the Compiler Plugin? The generated JAR can still be run on Java 17.



-- 
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] refactor: simplify annotation processor incremental handling [maven-compiler-plugin]

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


##########
src/test/java/org/apache/maven/plugin/compiler/incremental/CompilerTestHelper.java:
##########
@@ -0,0 +1,98 @@
+/*
+ * 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.tools.JavaCompiler;
+import javax.tools.StandardLocation;
+import javax.tools.ToolProvider;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Stream;
+
+/**
+ * Shared helper for incremental compilation tests.
+ */
+class CompilerTestHelper {
+
+    static void writeSource(Path sourceDir, String packageName, String className, String source) throws IOException {
+        Path packageDir = sourceDir.resolve(packageName.replace('.', '/'));
+        Files.createDirectories(packageDir);
+        Files.writeString(packageDir.resolve(className + ".java"), source);
+    }
+
+    /**
+     * Compiles all {@code .java} files under {@code sourceDir} into {@code outputDir}
+     * and returns the output directory. No ABI analysis — raw javac only.
+     */
+    static Map<String, SourceFileAnalysis> compileAndAnalyze(Path sourceDir, Path outputDir) throws IOException {
+        Files.createDirectories(outputDir);
+        List<Path> sourceFiles;
+        try (Stream<Path> walk = Files.walk(sourceDir)) {
+            sourceFiles =
+                    walk.filter(p -> p.toString().endsWith(".java")).sorted().toList();
+        }
+
+        JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
+        try (var fm = compiler.getStandardFileManager(null, null, null)) {
+            fm.setLocation(StandardLocation.CLASS_OUTPUT, List.of(outputDir.toFile()));
+            var units = fm.getJavaFileObjectsFromPaths(sourceFiles);
+            var task = compiler.getTask(null, fm, null, null, null, units);
+
+            if (!task.call()) {
+                throw new RuntimeException("Compilation failed");
+            }
+
+            // Build a minimal SourceFileAnalysis map from bytecode (no dep graph needed here)
+            Map<String, SourceFileAnalysis> results = new java.util.LinkedHashMap<>();
+            for (Path sf : sourceFiles) {
+                try (Stream<Path> walk = Files.walk(outputDir)) {
+                    walk.filter(p -> p.toString().endsWith(".class")).forEach(cf -> {
+                        try {
+                            var analysis = BytecodeAnalyzer.analyze(cf);
+                            var sfa = new SourceFileAnalysis(
+                                    analysis.className(),
+                                    sf.toString(),
+                                    analysis.signatureTypes(),
+                                    analysis.implementationTypes(),
+                                    analysis.abiFingerprint(),
+                                    analysis.abiCanonical(),
+                                    analysis.annotationTypes(),

Review Comment:
   💡 **Low — Wasteful iteration in test helper.** The outer `for (Path sf : sourceFiles)` loop walks the entire output directory once per source file, but the inner walk doesn't filter by `sf` — every `.class` file is analyzed and mapped to `sf.toString()` regardless of which source file produced it. The `results` map deduplicates by `className` (last writer wins), so this doesn't cause test failures, but it means:
   
   1. Class files are analyzed `O(sourceFiles × classFiles)` times instead of `O(classFiles)` once
   2. Every type gets attributed to the *last* source file in the iteration, not the one that actually defines it
   
   Since this is test-only code and the `sourceFile` field isn't validated in these tests, this is cosmetic — but it would break if a test ever asserted `sourceFile` accuracy from this helper.
   
   ```suggestion
               // Build a minimal SourceFileAnalysis map from bytecode (no dep graph needed here)
               Map<String, SourceFileAnalysis> results = new java.util.LinkedHashMap<>();
               try (Stream<Path> walk = Files.walk(outputDir)) {
                   walk.filter(p -> p.toString().endsWith(".class")).forEach(cf -> {
                       try {
                           var analysis = BytecodeAnalyzer.analyze(cf);
                           var sfa = new SourceFileAnalysis(
                                   analysis.className(),
                                   cf.toString(),
                                   analysis.signatureTypes(),
                                   analysis.implementationTypes(),
                                   analysis.abiFingerprint(),
                                   analysis.abiCanonical(),
                                   analysis.annotationTypes(),
                                   analysis.moduleName());
                           results.put(analysis.className(), sfa);
                       } catch (IOException e) {
                           // best effort
                       }
                   });
               }
   ```



-- 
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] Add ABI-fingerprint-based incremental compilation strategy [maven-compiler-plugin]

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


##########
pom.xml:
##########
@@ -192,6 +192,34 @@ under the License.
         </plugin>
       </plugins>
     </pluginManagement>
+    <plugins>
+      <plugin>
+        <groupId>org.apache.maven.plugins</groupId>
+        <artifactId>maven-compiler-plugin</artifactId>
+        <executions>
+          <execution>
+            <!--
+              Compile sources that require java.lang.classfile (JDK 24+) separately
+              with release=24, so the main sources stay at the baseline Java version.
+              The compiled classes land alongside the main classes in the output directory.
+              BytecodeAnalyzer loads ClassfileClassAnalyzer reflectively at runtime when
+              the running JVM is JDK 24 or later; AsmClassAnalyzer is used otherwise.
+            -->
+            <id>compile-java24</id>
+            <goals>
+              <goal>compile</goal>
+            </goals>
+            <phase>compile</phase>
+            <configuration>
+              <compileSourceRoots>
+                <compileSourceRoot>${project.basedir}/src/main/java24</compileSourceRoot>
+              </compileSourceRoots>
+              <release>24</release>

Review Comment:
   Fixed in 950b860. Moved the `compile-java24` execution into a `java24+` profile with `<jdk>[24,)</jdk>` activation. On JDK < 24, the execution is skipped entirely.



##########
src/test/java/org/apache/maven/plugin/compiler/incremental/BytecodeAnalyzerTest.java:
##########
@@ -0,0 +1,180 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.plugin.compiler.incremental;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class BytecodeAnalyzerTest {
+
+    @TempDir
+    Path sourceDir;
+
+    @TempDir
+    Path outputDir;
+
+    @Test
+    void descriptorToReadableVoid() {
+        assertEquals("void", BytecodeAnalyzer.descriptorToReadable("V"));
+    }
+
+    @Test
+    void descriptorToReadableBoolean() {
+        assertEquals("boolean", BytecodeAnalyzer.descriptorToReadable("Z"));
+    }
+
+    @Test
+    void descriptorToReadableByte() {
+        assertEquals("byte", BytecodeAnalyzer.descriptorToReadable("B"));
+    }
+
+    @Test
+    void descriptorToReadableChar() {
+        assertEquals("char", BytecodeAnalyzer.descriptorToReadable("C"));
+    }
+
+    @Test
+    void descriptorToReadableShort() {
+        assertEquals("short", BytecodeAnalyzer.descriptorToReadable("S"));
+    }
+
+    @Test
+    void descriptorToReadableInt() {
+        assertEquals("int", BytecodeAnalyzer.descriptorToReadable("I"));
+    }
+
+    @Test
+    void descriptorToReadableLong() {
+        assertEquals("long", BytecodeAnalyzer.descriptorToReadable("J"));
+    }
+
+    @Test
+    void descriptorToReadableFloat() {
+        assertEquals("float", BytecodeAnalyzer.descriptorToReadable("F"));
+    }
+
+    @Test
+    void descriptorToReadableDouble() {
+        assertEquals("double", BytecodeAnalyzer.descriptorToReadable("D"));
+    }
+
+    @Test
+    void descriptorToReadableObject() {
+        assertEquals("java.lang.String", BytecodeAnalyzer.descriptorToReadable("Ljava/lang/String;"));
+    }
+
+    @Test
+    void descriptorToReadableArray() {
+        assertEquals("int[]", BytecodeAnalyzer.descriptorToReadable("[I"));
+        assertEquals("java.lang.String[]", BytecodeAnalyzer.descriptorToReadable("[Ljava/lang/String;"));
+    }
+
+    @Test
+    void descriptorToReadableEmpty() {
+        assertEquals("", BytecodeAnalyzer.descriptorToReadable(""));
+    }
+
+    @Test
+    void parseParamsMultiple() {
+        assertEquals("java.lang.String, int", BytecodeAnalyzer.parseParams("(Ljava/lang/String;I)V"));
+    }
+
+    @Test
+    void parseParamsEmpty() {
+        assertEquals("", BytecodeAnalyzer.parseParams("()V"));
+    }
+
+    @Test
+    void parseReturnType() {
+        assertEquals("java.lang.String", BytecodeAnalyzer.parseReturn("(I)Ljava/lang/String;"));
+        assertEquals("void", BytecodeAnalyzer.parseReturn("()V"));
+        assertEquals("int", BytecodeAnalyzer.parseReturn("()I"));
+    }
+
+    @Test
+    void analyzePathProducesCorrectClassName() throws Exception {
+        CompilerTestHelper.writeSource(
+                sourceDir,
+                "test",
+                "Hello",
+                "package test; public class Hello { public String greet() { return \"hi\"; } }");
+        CompilerTestHelper.compileAndAnalyze(sourceDir, outputDir);
+
+        Path classFile = outputDir.resolve("test/Hello.class");
+        assertTrue(Files.exists(classFile));
+
+        var analysis = BytecodeAnalyzer.analyze(classFile);
+        assertEquals("test.Hello", analysis.className());
+        assertNotNull(analysis.abiFingerprint());
+        assertNotNull(analysis.abiCanonical());
+    }
+
+    @Test
+    void analyzeBytesEquivalentToAnalyzePath() throws Exception {
+        CompilerTestHelper.writeSource(
+                sourceDir, "test", "Hello", "package test; public class Hello { public int value() { return 42; } }");
+        CompilerTestHelper.compileAndAnalyze(sourceDir, outputDir);
+
+        Path classFile = outputDir.resolve("test/Hello.class");
+        var fromPath = BytecodeAnalyzer.analyze(classFile);
+        var fromBytes = BytecodeAnalyzer.analyze(Files.readAllBytes(classFile));
+
+        assertEquals(fromPath.className(), fromBytes.className());
+        assertEquals(fromPath.abiFingerprint(), fromBytes.abiFingerprint());
+        assertEquals(fromPath.abiCanonical(), fromBytes.abiCanonical());
+        assertEquals(fromPath.referencedTypes(), fromBytes.referencedTypes());
+    }
+
+    /**
+     * When running on JDK 24+, {@link BytecodeAnalyzer} uses {@link ClassfileClassAnalyzer}
+     * instead of {@link AsmClassAnalyzer}. Both must produce identical results for the same
+     * class file. This test cross-validates the two implementations against each other.
+     */
+    @Test
+    void asmAndClassfileAnalyzersProduceIdenticalResults() throws Exception {
+        CompilerTestHelper.writeSource(sourceDir, "test", "Subject", """
+                package test;
+                public class Subject implements Runnable {
+                    public static final String CONST = "hello";
+                    private int secret = 42;
+                    public String greet(int x, java.util.List<String> items) { return CONST + x; }
+                    @Override public void run() { greet(1, null); }
+                    protected static int helper(byte b) throws java.io.IOException { return b; }
+                }
+                """);
+        CompilerTestHelper.compileAndAnalyze(sourceDir, outputDir);
+
+        byte[] bytes = Files.readAllBytes(outputDir.resolve("test/Subject.class"));
+
+        var asmResult = new AsmClassAnalyzer().analyze(bytes);
+        var cfResult = new ClassfileClassAnalyzer().analyze(bytes);

Review Comment:
   Fixed in 950b860. Added `@EnabledForJreRange(min = JAVA_24)` and switched to reflective instantiation of `ClassfileClassAnalyzer` to avoid a compile-time dependency on the JDK 24+ class.



##########
pom.xml:
##########
@@ -192,6 +192,60 @@ under the License.
         </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.

Review Comment:
   Fixed in 9a5c56d. CI workflow now uses `ff-jdk: '25'` and `jdk-matrix: '["25"]'` to match the enforcer `[25,)` requirement.



##########
src/main/java/org/apache/maven/plugin/compiler/ToolExecutor.java:
##########
@@ -915,6 +919,201 @@ 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 {
+        if (compiler instanceof ForkedTool) {
+            logger.warn("ABI incremental strategy is not supported with forked compilation."
+                    + " Falling back to full compilation.");
+        }
+        var abiBuild = new AbiIncrementalBuild(outputDirectory);

Review Comment:
   Fixed in e79f971. Calling `abiBuild.invalidate()` right after construction when `ForkedTool` is detected, so `initialize()` deletes the stale state file and returns all files — making the full compilation fallback correct.



##########
src/main/java/org/apache/maven/plugin/compiler/incremental/BytecodeAnalyzer.java:
##########
@@ -0,0 +1,366 @@
+/*
+ * 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.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.MessageDigest;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+import java.util.TreeSet;
+
+import org.objectweb.asm.AnnotationVisitor;
+import org.objectweb.asm.ClassReader;
+import org.objectweb.asm.ClassVisitor;
+import org.objectweb.asm.FieldVisitor;
+import org.objectweb.asm.MethodVisitor;
+import org.objectweb.asm.Opcodes;
+import org.objectweb.asm.Type;
+
+/**
+ * Analyzes compiled {@code .class} files using ASM to extract type references
+ * and compute bytecode-level ABI fingerprints.
+ *
+ * <p>Type references are collected from the class hierarchy, field/method
+ * descriptors, exception types, annotations, and bytecode instructions
+ * ({@code new}, {@code checkcast}, {@code instanceof}, field/method owners).
+ *
+ * <p>The bytecode ABI reflects erased types (no generics) and is computed from
+ * non-private, non-synthetic fields and methods.
+ *
+ * @see AbiExtractor
+ * @see DependencyScanner
+ */
+public class BytecodeAnalyzer {
+
+    public record ClassAnalysis(
+            String className, String abiFingerprint, String abiCanonical, Set<String> referencedTypes) {}
+
+    public static ClassAnalysis analyze(Path classFile) throws IOException {
+        return analyze(Files.readAllBytes(classFile));
+    }
+
+    public static ClassAnalysis analyze(byte[] classBytes) {

Review Comment:
   Done in 35bb1d2 + 950b860 + the current state. The plugin is now a proper multi-release JAR: `java.lang.classfile` is used on JDK 24+, with ASM as fallback on older JDKs. The JDK 24+ implementation lives in `src/main/java24/`, and `BytecodeAnalyzer.selectAnalyzer()` picks the right implementation reflectively at runtime.



-- 
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] refactor: simplify annotation processor incremental handling [maven-compiler-plugin]

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


##########
src/main/java/org/apache/maven/plugin/compiler/AbstractCompilerMojo.java:
##########
@@ -682,6 +683,35 @@ 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 graph} — dependency-graph-based strategy. Tracks class-level dependencies
+     *       by analysing bytecode after each compilation pass. When a source file changes, only
+     *       the source files that transitively depend on any of its classes are recompiled. Any
+     *       change to a class (API or implementation) cascades to all its consumers.
+     *       Full JPMS support including {@code module-info.java} and
+     *       {@code module-info-patch.maven} tracking.
+     *       <p>Note: the graph strategy has its own change detection (content hashing) and does
+     *       not use {@code staleMillis}, {@code incrementalExcludes}, or the
+     *       {@link #incrementalCompilation} aspects. Setting {@link #useIncrementalCompilation}
+     *       to {@code false} disables this strategy and forces a full rebuild.</p></li>
+     *   <li>{@code abi} — ABI-fingerprint-based strategy. Tracks class-level dependencies
+     *       and public API surface (ABI) fingerprints via bytecode analysis. When a source file
+     *       changes, only files whose ABI actually changed cascade to their consumers; body-only
+     *       changes recompile only the changed file. Accepted as an alias for {@code graph}.</li>
+     * </ul>
+     *
+     * @since 4.0.0-beta-7
+     */
+    @Parameter(property = "maven.compiler.incrementalStrategy", defaultValue = "timestamp")

Review Comment:
   ⚠️ **Warning (medium): `abi` strategy Javadoc is misleading.** The description says:
   
   > *only files whose ABI actually changed cascade to their consumers; body-only changes recompile only the changed file*
   
   But `processCompiledClasses()` cascades ALL compiled types regardless of whether their ABI fingerprint changed (line ~2108: `changedTypes = new TreeSet<>(results.keySet())`). The `abi` flag only controls whether ABI fingerprints and fine-grained sig/impl deps are *stored* — it doesn't reduce cascading based on fingerprint comparison.
   
   The actual distinction between `graph` and `abi` is:
   - `graph`: all deps go into `signatureConsumersIndex` → transitive cascade for everything
   - `abi`: separate sig/impl indexes → transitive cascade only through signature consumers; implementation consumers cascade directly but not transitively
   
   Plus `abi` writes the `abi-fingerprints` manifest for cross-module tracking.
   
   Either implement ABI-fingerprint-based cascade pruning in `processCompiledClasses()` (compare new fingerprint vs stored fingerprint, skip cascade if unchanged), or fix the Javadoc to accurately describe the current behavior:
   
   ```suggestion
        *   <li>{@code abi} — ABI-aware dependency-graph strategy. Extends {@code graph} with
        *       fine-grained signature/implementation dependency classification and ABI fingerprints.
        *       Signature changes cascade transitively to consumers; implementation-only dependency
        *       changes cascade directly but not transitively. Writes an ABI manifest for cross-module
        *       incremental detection in reactor builds.</li>
   ```



##########
src/it/abi-incremental-basic/pom.xml:
##########
@@ -0,0 +1,66 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+  ~ Licensed to the Apache Software Foundation (ASF) under one
+  ~ or more contributor license agreements.  See the NOTICE file
+  ~ distributed with this work for additional information
+  ~ regarding copyright ownership.  The ASF licenses this file
+  ~ to you under the Apache License, Version 2.0 (the
+  ~ "License"); you may not use this file except in compliance
+  ~ with the License.  You may obtain a copy of the License at
+  ~
+  ~   http://www.apache.org/licenses/LICENSE-2.0
+  ~
+  ~ Unless required by applicable law or agreed to in writing,
+  ~ software distributed under the License is distributed on an
+  ~ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+  ~ KIND, either express or implied.  See the License for the
+  ~ specific language governing permissions and limitations
+  ~ under the License.
+  -->
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
+  <modelVersion>4.0.0</modelVersion>
+
+  <groupId>org.apache.maven.plugins.compiler.it</groupId>
+  <artifactId>abi-incremental-basic</artifactId>
+  <version>1.0-SNAPSHOT</version>
+
+  <description>IT: ABI-based incremental compilation — body-only change should only recompile the changed file.</description>
+
+  <properties>

Review Comment:
   🔴 **Critical: IT will fail — manifest assertion vs strategy mismatch.** This IT uses `incrementalStrategy=graph`, but `verify.groovy` (line 14 of that file) asserts that `target/abi-fingerprints` exists. The manifest is only written when `abiTracking=true`, which requires `incrementalStrategy=abi`.
   
   `GraphIncrementalBuild.finish()` → `if (abiTracking) { AbiManifest.write(...) }` — with `graph` strategy, `abiTracking=false`, so no manifest is written.
   
   Either change the strategy to `abi` to match the IT's name and assertions:
   
   ```suggestion
       <maven.compiler.incrementalStrategy>abi</maven.compiler.incrementalStrategy>
   ```
   
   Or remove the manifest assertions from `verify.groovy`.



##########
src/it/abi-incremental-cascade/pom.xml:
##########
@@ -0,0 +1,67 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+  ~ Licensed to the Apache Software Foundation (ASF) under one
+  ~ or more contributor license agreements.  See the NOTICE file
+  ~ distributed with this work for additional information
+  ~ regarding copyright ownership.  The ASF licenses this file
+  ~ to you under the Apache License, Version 2.0 (the
+  ~ "License"); you may not use this file except in compliance
+  ~ with the License.  You may obtain a copy of the License at
+  ~
+  ~   http://www.apache.org/licenses/LICENSE-2.0
+  ~
+  ~ Unless required by applicable law or agreed to in writing,
+  ~ software distributed under the License is distributed on an
+  ~ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+  ~ KIND, either express or implied.  See the License for the
+  ~ specific language governing permissions and limitations
+  ~ under the License.
+  -->
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
+  <modelVersion>4.0.0</modelVersion>
+
+  <groupId>org.apache.maven.plugins.compiler.it</groupId>
+  <artifactId>abi-incremental-cascade</artifactId>
+  <version>1.0-SNAPSHOT</version>
+
+  <description>IT: ABI change should cascade to consumers (recompile Service when Model API changes).</description>
+
+  <properties>

Review Comment:
   🔴 **Same issue as `abi-incremental-basic`** — uses `incrementalStrategy=graph` but `verify.groovy` asserts `graph cascade: recompiling`. The cascade assertion is valid for `graph`, but the IT name `abi-incremental-cascade` is misleading.
   
   The cascade verify.groovy doesn't assert manifest existence (so it won't fail for the same reason as the basic IT), but the name should match the strategy. Consider renaming to `graph-incremental-cascade` or switching to `incrementalStrategy=abi`.



##########
src/main/java/org/apache/maven/plugin/compiler/ToolExecutor.java:
##########
@@ -915,6 +919,244 @@ private static boolean removeFirsts(Deque<Path> paths, Integer count) {
         }
     }
 
+    /**
+     * Compiles using the dependency-graph incremental strategy. This method handles the full
+     * lifecycle: determining what to compile, running javac, cascading on changed classes,
+     * and persisting state.
+     *
+     * @param compiler the compiler
+     * @param configuration the options to give to the Java compiler
+     * @param mojo the MOJO for configuration access
+     * @throws IOException if an error occurred while reading or writing a file
+     * @throws MojoException if the compilation failed
+     */
+    void compileWithGraphIncremental(JavaCompiler compiler, Options configuration, AbstractCompilerMojo mojo)
+            throws IOException {
+        var graphBuild = new GraphIncrementalBuild(outputDirectory);
+
+        // Collect annotation processor path for processor classification
+        var processorPaths = new ArrayList<Path>();
+        for (var entry : dependencies.entrySet()) {
+            if (entry.getKey() instanceof JavaPathType type) {
+                var location = type.location();
+                if (location.isPresent()
+                        && (location.get() == StandardLocation.ANNOTATION_PROCESSOR_PATH
+                                || location.get() == StandardLocation.ANNOTATION_PROCESSOR_MODULE_PATH)) {
+                    processorPaths.addAll(entry.getValue());
+                }
+            }
+        }

Review Comment:
   💡 **Note (low): MODULE_PATH entries not collected for external ABI tracking.** The `compileWithGraphIncremental` method collects entries from `CLASS_PATH` and `ANNOTATION_PROCESSOR_PATH`/`ANNOTATION_PROCESSOR_MODULE_PATH`, but does not collect entries from `MODULE_PATH`. For JPMS reactor projects where dependencies are on the module path rather than the classpath, external ABI changes in those dependencies won't be detected.
   
   This may be acceptable for an initial implementation (non-modular projects are the common case), but worth tracking as a follow-up — a modular reactor project where module A depends on module B via `requires` would not detect ABI changes in B.



##########
src/main/java/org/apache/maven/plugin/compiler/incremental/GraphIncrementalBuild.java:
##########
@@ -0,0 +1,947 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.plugin.compiler.incremental;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeSet;
+import java.util.stream.Collectors;
+
+/**
+ * Dependency-graph-driven incremental build engine, designed for embedding
+ * in maven-compiler-plugin alongside the existing timestamp-based
+ * {@code IncrementalBuild}.
+ *
+ * <p>The plugin drives compilation; this class determines <em>what</em> to
+ * compile and performs post-compilation bytecode analysis to build the
+ * class-level dependency graph. Typical usage:
+ *
+ * {@snippet :
+ * var build = new GraphIncrementalBuild(outputDir);
+ * build.setProcessorPath(processorPath);
+ * build.setConfigHash(configHash);
+ *
+ * Set<Path> toCompile = build.initialize(allSourceFiles);
+ *
+ * while (!toCompile.isEmpty()) {
+ *     compiler.compile(toCompile);      // any compiler, any mode
+ *     toCompile = build.processCompiledClasses(toCompile);
+ * }
+ *
+ * build.finish();
+ * }
+ *
+ * <p>After each compilation pass, {@link #processCompiledClasses(Set)} scans
+ * the freshly produced {@code .class} files, updates the dependency graph,
+ * and returns any additional source files that must be compiled in the next
+ * pass (cascade due to changed classes, or newly discovered dependencies).
+ * The loop converges in at most 2–3 passes in practice.
+ *
+ * <p>The engine persists its state as {@code incremental-state} in the
+ * {@code target/maven-status/maven-compiler-plugin/<outputDirName>/} directory (outside the class
+ * output directory so it is not packaged into JARs).
+ *
+ * @see IncrementalState
+ */
+public class GraphIncrementalBuild {
+
+    /** Prefix used to distinguish module-info entries from regular type entries in the state. */
+    static final String MODULE_PREFIX = BytecodeAnalyzer.MODULE_PREFIX;
+
+    private final Path outputDir;
+    private final Path buildDir;
+    private final Path stateFile;
+    private List<Path> classpathEntries;
+    private Set<Path> reactorModulePaths;
+    private boolean abiTracking;
+    private List<Path> processorPath;
+    private ProcessorClassification processorClassification;
+
+    private IncrementalState previousState;
+    private IncrementalState state;
+
+    /** Package-private accessor for tests. */
+    IncrementalState getState() {
+        return state;
+    }
+
+    private Map<String, String> sourceHashes;
+    private Map<String, Long> sourceMtimes;
+    private List<Path> allSourceFiles;
+    private Set<String> allCompiled;
+    private boolean fullBuild;
+    private boolean useModulePrefixedPaths;
+    private String configHash = "";
+    private String rebuildCause;
+    private int totalSources;
+    /** Lazily populated on full builds; maps each output class file to its simple top-level class name. */
+    private Map<Path, String> outputClassIndex;
+
+    public GraphIncrementalBuild(Path outputDir) {
+        this.outputDir = outputDir.toAbsolutePath();
+        this.buildDir = this.outputDir.getParent() != null ? this.outputDir.getParent() : this.outputDir;
+        // Store state outside the output directory so it is not included in the JAR.
+        // Use the same maven-status convention as the timestamp-based strategy.
+        // Include the output directory name (e.g. "classes", "test-classes") to avoid
+        // collisions between compile and testCompile executions.
+        String outputDirName = outputDir.getFileName().toString();
+        Path mavenStatus = buildDir.resolve("maven-status").resolve("maven-compiler-plugin");
+        this.stateFile = mavenStatus.resolve(outputDirName).resolve("incremental-state");
+    }
+
+    /**
+     * Sets classpath entries for 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;
+    }
+
+    /**
+     * Enables ABI tracking mode ({@code abi} strategy). When {@code true}, the
+     * engine computes ABI fingerprints and fine-grained {@code signatureDeps}/
+     * {@code implementationDeps} for each compiled type, and writes an
+     * {@link AbiManifest} on {@link #finish()}. When {@code false} (default,
+     * {@code graph} strategy), only class-level dependency references are collected.
+     */
+    public void setAbiTracking(boolean abiTracking) {
+        this.abiTracking = abiTracking;
+    }
+
+    /**
+     * Sets the annotation processor classpath for processor classification.
+     * Entries are scanned for {@code META-INF/maven/compiler/incremental.annotation.processors}
+     * and {@code META-INF/gradle/incremental.annotation.processors} to determine
+     * whether each processor is {@link ProcessorType#ISOLATING},
+     * {@link ProcessorType#AGGREGATING}, or {@link ProcessorType#UNKNOWN}.
+     */
+    public void setProcessorPath(List<Path> processorPath) {
+        this.processorPath = processorPath;
+        this.processorClassification = new ProcessorClassification(processorPath);
+    }
+
+    /**
+     * Indicates that class files are written under module-name subdirectories
+     * of the output directory (MODULE_SOURCE hierarchy). When set, stored module
+     * names are used as path prefixes when deleting class files.
+     */
+    public void setUseModulePrefixedPaths(boolean useModulePrefixedPaths) {
+        this.useModulePrefixedPaths = useModulePrefixedPaths;
+    }
+
+    /**
+     * Sets a hash of compilation context configuration (e.g. compiler options
+     * and module-info-patch files). If this hash differs from the previous
+     * build, a full rebuild is triggered.
+     */
+    public void setConfigHash(String hash) {
+        this.configHash = hash != null ? hash : "";
+    }
+
+    /**
+     * Initializes the incremental build by scanning source files and comparing
+     * against the previous build's state.
+     *
+     * @param allSourceFiles all source files in this module
+     * @return the set of files that need compilation (may be all files for a
+     *         full build, a subset for incremental, or empty if up-to-date)
+     */
+    public Set<Path> initialize(List<Path> allSourceFiles) throws IOException {
+        Files.createDirectories(outputDir);
+
+        this.allSourceFiles = allSourceFiles;
+        totalSources = allSourceFiles.size();
+        allCompiled = new TreeSet<>();
+        previousState = IncrementalState.load(stateFile);
+        sourceMtimes = new LinkedHashMap<>();
+        sourceHashes = hashSourceFiles(allSourceFiles, previousState, sourceMtimes);
+
+        if (previousState == null) {
+            rebuildCause = "no previous build state";
+            return initFullBuild(allSourceFiles);
+        } else if (!configHash.equals(previousState.getConfigHash())) {
+            rebuildCause = "compilation configuration changed (module-info-patch.maven or compiler options)";
+            return initFullBuild(allSourceFiles);
+        } else {
+            return initIncrementalBuild(allSourceFiles);
+        }
+    }
+
+    /**
+     * Processes the {@code .class} files produced by the last compilation pass.
+     * Scans each class file to extract the dependency graph, records changes,
+     * and returns any additional source files that must be compiled in the next pass.
+     *
+     * <p>The cascade logic: any compiled class whose content changed (new or modified)
+     * triggers recompilation of all source files that depend on it (signature or
+     * implementation consumers).
+     *
+     * @param compiledSourceFiles the source files that were passed to the compiler in this round
+     * @return additional source files to compile (may be empty when fixpoint is reached)
+     * @throws IOException if reading {@code .class} files fails
+     */
+    public Set<Path> processCompiledClasses(Set<Path> compiledSourceFiles) throws IOException {
+        // Reset the class index so it is rebuilt fresh for each compilation round
+        outputClassIndex = null;
+
+        // Scan .class files for the types produced from the compiled source files
+        var results = new LinkedHashMap<String, SourceFileAnalysis>();
+        for (Path sourceFile : compiledSourceFiles) {
+            collectClassAnalyses(sourceFile, results);
+        }
+
+        // All compiled types cascade to their consumers (any change triggers recompilation)
+        var changedTypes = new TreeSet<>(results.keySet());
+
+        // Update incremental state with this round's results
+        for (var entry : sourceHashes.entrySet()) {
+            if (allCompiled.contains(entry.getKey())) {
+                state.setSourceHash(entry.getKey(), entry.getValue());
+            }
+        }
+        for (var result : results.values()) {
+            String moduleName = useModulePrefixedPaths ? result.moduleName() : "";
+            IncrementalState.TypeInfo typeInfo;
+            if (abiTracking) {
+                typeInfo = new IncrementalState.AbiTypeInfo(
+                        result.sourceFile(),
+                        result.classDeps(),
+                        result.annotationTypes(),
+                        moduleName,
+                        result.signatureDeps(),
+                        result.implementationDeps(),
+                        result.abiFingerprint());
+            } else {
+                typeInfo = new IncrementalState.GraphTypeInfo(
+                        result.sourceFile(), result.classDeps(), result.annotationTypes(), moduleName);
+            }
+            state.setType(result.qualifiedName(), typeInfo);
+        }
+
+        if (fullBuild || changedTypes.isEmpty()) {
+            return Set.of();
+        }
+
+        // Detect module name changes — require a full rebuild
+        if (previousState != null && hasModuleNameChanged(state, previousState)) {
+            return forceFullRebuild();
+        }
+
+        // Cascade: find all consumers of changed types (both signature and implementation)
+        var cascade = new TreeSet<>(changedTypes);
+        for (String type : changedTypes) {
+            expandSignatureCascade(type, state, cascade);
+        }
+
+        var additionalFiles = new TreeSet<Path>();
+        for (String cascadedType : cascade) {
+            for (String consumer : state.getAllConsumers(cascadedType)) {
+                String sf = state.sourceFileFor(consumer);
+                if (sf != null && !allCompiled.contains(sf)) {
+                    additionalFiles.add(Path.of(sf));
+                    allCompiled.add(sf);
+                }
+            }
+        }
+
+        // Annotation processor cascade
+        additionalFiles.addAll(computeProcessorCascade());
+
+        return additionalFiles;
+    }
+
+    /**
+     * Scans the output directory for {@code .class} files produced from the given source file,
+     * analyzes each one with {@link BytecodeAnalyzer}, and accumulates {@link SourceFileAnalysis}
+     * records into {@code results}.
+     *
+     * <p>The source→class mapping is reconstructed by looking up types previously recorded for
+     * this source file in the previous state, and by scanning the output directory for class
+     * files whose name prefix matches the source file's simple name. This covers both primary
+     * classes and inner/anonymous classes ({@code Foo$Bar.class}).
+     */
+    private void collectClassAnalyses(Path sourceFile, Map<String, SourceFileAnalysis> results) throws IOException {
+        String sourceFilePath = sourceFile.toString();
+        String simpleSourceName = sourceFile.getFileName().toString(); // e.g. "Model.java"
+
+        // Map from class file path → pre-computed analysis (null if not yet analyzed).
+        // Reuses the analysis from the package-dir walk to avoid double BytecodeAnalyzer.analyze calls.
+        var classFileCache = new LinkedHashMap<Path, BytecodeAnalyzer.ClassAnalysis>();
+
+        // Types previously tracked for this source file — their .class files may have moved
+        if (previousState != null) {
+            for (String type : previousState.getTypesFromSource(sourceFilePath)) {
+                Path classFile = classFileFor(type, previousState.getType(type));
+                if (Files.exists(classFile)) {
+                    classFileCache.put(classFile, null);
+                }
+                // Also scan for inner classes (Foo$Bar.class etc.)
+                var innerFiles = new TreeSet<Path>();
+                addInnerClassFiles(classFile, innerFiles);
+                for (Path inner : innerFiles) {
+                    classFileCache.putIfAbsent(inner, null);
+                }
+            }
+        }
+
+        // Walk output directory entries for this source's simple name
+        // (handles new types introduced in this compilation)
+        Path packageDir = inferPackageDir(sourceFile);
+        if (packageDir != null && Files.isDirectory(packageDir)) {
+            try (var stream = Files.list(packageDir)) {
+                stream.filter(p -> p.toString().endsWith(".class")).forEach(cf -> {
+                    try {
+                        // Match by SourceFile attribute — covers primary class, inner/anonymous
+                        // classes (Foo$Bar.class), AND package-private secondary top-level classes
+                        // (FooHelper in Foo.java), without cross-package simple-name collisions.
+                        var a = BytecodeAnalyzer.analyze(cf);
+                        if (simpleSourceName.equals(a.sourceFileName())) {
+                            classFileCache.put(cf, a); // cache the analysis for reuse below
+                        }
+                    } catch (IOException e) {
+                        // Log at debug level — a corrupted or inaccessible class file is silently
+                        // skipped; the missing type entry will trigger a full rebuild next time.
+                        System.getLogger(GraphIncrementalBuild.class.getName())
+                                .log(System.Logger.Level.DEBUG, "Failed to analyze class file: {0}", cf);
+                    }
+                });
+            }
+        } else {
+            // No previous state for this source (full build or new file in incremental build):
+            // use the cached class index keyed by SourceFile attribute value (simple source name,
+            // e.g. "Foo.java"). This correctly associates package-private secondary types and
+            // avoids cross-package simple-name collisions.
+            for (var entry : getFullBuildClassIndex().entrySet()) {
+                if (simpleSourceName.equals(entry.getValue())) {
+                    classFileCache.putIfAbsent(entry.getKey(), null);
+                }
+            }
+        }
+
+        // Special case: module-info.class — check both flat and module-prefixed locations
+        if (sourceFile.getFileName().toString().equals("module-info.java")) {
+            // Flat layout: outputDir/module-info.class
+            Path flat = outputDir.resolve("module-info.class");
+            if (Files.exists(flat)) {
+                classFileCache.putIfAbsent(flat, null);
+            }
+            // MODULE_SOURCE layout: outputDir/<moduleName>/module-info.class
+            if (useModulePrefixedPaths && previousState != null) {
+                // Find the module name from previous state
+                for (String type : previousState.getTypesFromSource(sourceFile.toString())) {
+                    if (type.startsWith(MODULE_PREFIX)) {
+                        String modName = type.substring(MODULE_PREFIX.length());
+                        Path modInfo = outputDir.resolve(modName).resolve("module-info.class");
+                        if (Files.exists(modInfo)) {
+                            classFileCache.putIfAbsent(modInfo, null);
+                        }
+                        break;
+                    }
+                }
+            }
+        }
+
+        // Final pass: analyze each class file (reusing cached analysis where available)
+        for (var cacheEntry : classFileCache.entrySet()) {
+            Path classFile = cacheEntry.getKey();
+            if (!Files.exists(classFile)) {
+                continue;
+            }
+            try {
+                if (abiTracking) {
+                    // Full ABI analysis: sig/impl deps + fingerprint
+                    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,
+                            unionDeps(analysis.signatureTypes(), analysis.implementationTypes()),
+                            analysis.signatureTypes(),
+                            analysis.implementationTypes(),
+                            analysis.abiFingerprint(),
+                            analysis.abiCanonical(),
+                            analysis.annotationTypes(),
+                            analysis.moduleName());
+                    results.put(analysis.className(), sfa);
+                } else {
+                    // Graph analysis: class-level deps only (constant pool scan)
+                    // We still need className, sourceFileName, moduleName, annotationTypes
+                    // from a lightweight analysis — use analyzeGraph for deps but full analyze
+                    // for metadata (still one parse of the classfile via the classfile API).
+                    var analysis =
+                            cacheEntry.getValue() != null ? cacheEntry.getValue() : BytecodeAnalyzer.analyze(classFile);
+                    String sourceFilePath2 = analysis.isModuleInfo()
+                            ? sourceFilePath
+                            : resolveSourceFile(analysis.className(), sourceFilePath);
+                    Set<String> classDeps = unionDeps(analysis.signatureTypes(), analysis.implementationTypes());
+                    var sfa = new SourceFileAnalysis(
+                            analysis.className(),
+                            sourceFilePath2,
+                            classDeps,
+                            Set.of(),

Review Comment:
   💡 **Note (low): `getFullBuildClassIndex()` walks the entire output tree.** On a full build, this walks all `.class` files in the output directory and runs `BytecodeAnalyzer.analyze()` on each one to read the `SourceFile` attribute. This happens once per source file via `collectClassAnalyses()` when `packageDir` is null (no previous state).
   
   The index is cached (`outputClassIndex`), so the walk happens at most once per round. But for large projects with thousands of class files, this duplicates the analysis work since every class file is also individually analyzed in the final pass of `collectClassAnalyses()`. The cached analysis (`classFileCache.putIfAbsent(entry.getKey(), null)`) doesn't reuse the analysis from the index walk — it stores `null` and re-analyzes.
   
   Not a correctness issue, but a performance opportunity for large codebases.



-- 
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] refactor: simplify annotation processor incremental handling [maven-compiler-plugin]

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

   Closing in favour of a new PR. The original #1144 was the monolithic ABI strategy PR; its content has been split into #1155 (ABI strategy) and a new PR for the annotation processor simplification on top of #1155.


-- 
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] Add ABI-fingerprint-based incremental compilation strategy [maven-compiler-plugin]

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


##########
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:
   Done — not deferred as a follow-up. `hashSourceFiles()` implements the mtime-first short-circuit: for each source file it reads `BasicFileAttributes`, compares `lastModifiedTime` against the value persisted in the previous `IncrementalState`, and only reads and re-hashes the file content when the mtime has changed. On the common no-change case, only `stat()` calls are made.



-- 
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] Add ABI-fingerprint-based incremental compilation strategy [maven-compiler-plugin]

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


##########
src/main/java/org/apache/maven/plugin/compiler/incremental/ClassAnalyzer.java:
##########
@@ -0,0 +1,250 @@
+/*
+ * 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.Files;
+import java.nio.file.Path;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Abstract base for analyzing compiled {@code .class} files.
+ *
+ * <p>The single built-in implementation is:
+ * <ul>
+ *   <li>{@code ClassfileClassAnalyzer} — uses the standard
+ *       {@code java.lang.classfile} API introduced in JDK 24; loaded
+ *       reflectively at runtime. Requires JDK 24 or later.</li>
+ * </ul>
+ *
+ * <p>Use {@link BytecodeAnalyzer#analyze(byte[])} which selects the best
+ * available implementation automatically.
+ *
+ * @see BytecodeAnalyzer
+ */
+public abstract class ClassAnalyzer {
+
+    // JVM spec access flag constants — shared by both implementations
+    protected static final int ACC_PUBLIC = 0x0001;
+    protected static final int ACC_PRIVATE = 0x0002;
+    protected static final int ACC_PROTECTED = 0x0004;
+    protected static final int ACC_STATIC = 0x0008;
+    protected static final int ACC_FINAL = 0x0010;
+    protected static final int ACC_INTERFACE = 0x0200;
+    protected static final int ACC_ABSTRACT = 0x0400;
+    protected static final int ACC_SYNTHETIC = 0x1000;
+    protected static final int ACC_ENUM = 0x4000;

Review Comment:
   Those constants are already available in `java.lang.reflect.Modifier`.



-- 
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] Add ABI-fingerprint-based incremental compilation strategy [maven-compiler-plugin]

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


##########
src/main/java/org/apache/maven/plugin/compiler/incremental/AbiManifest.java:
##########
@@ -0,0 +1,116 @@
+/*
+ * 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.Files;
+import java.nio.file.Path;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+
+/**
+ * Reads and writes ABI fingerprint manifests for cross-module incremental

Review Comment:
   We mean "cross sub-project"?



-- 
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] refactor: simplify annotation processor incremental handling [maven-compiler-plugin]

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

   I started the review, but it will be large effort to complete it. Can we break this pull request into smaller ones? My analysis is that this pull request mixes many aspects that could be treated independently:
   
   1. Adding dependency graph for replacing the _one-to-one_ relationship by _one-to-many_ between modified files and compiled files.
   2. Adding ABI for replacing "modified files" by "_effectively_ modified files" from the point of view of API consumers.
   3. Saving ABI in a manifest file for detecting when a change in a sub-project requires a recompilation of another sub-project.
   4. Detecting the type of annotation processors (using non-standard metadata) for deciding which change detection strategy to use.
   
   I don't see any reason for addressing those 4 aspects in the same pull request. In my opinion, they can be addressed separately. Can we focus on 1 only for starting?


-- 
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] refactor: simplify annotation processor incremental handling [maven-compiler-plugin]

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

   In a [previous comment that I wrote about an AI answer to an older comment](https://github.com/apache/maven-compiler-plugin/pull/1144#issuecomment-5984123261), I pointed out that the AI answer was not correct. The [latest AI answer acknowledge this error](https://github.com/apache/maven-compiler-plugin/pull/1144#issuecomment-5984278965) but that answer is itself a little bit worrying. The answer talks about tracking dependencies in a JAR file and doing a full rebuild in a "all or nothing" fashion. This is step 3 in my [proposed list of tasks](https://github.com/apache/maven-compiler-plugin/pull/1144#issuecomment-5984536369), not step 1. It makes me worrying that the AI is going to mix the steps.
   
   I have not yet analysed the large amount of code and comments generated by the AI related to this ABI task. I hope to take more time after the upcoming Apache Community over Code conference. I wish that these pull requests are not merged too quickly. I do not trust the AI at all for doing this work correctly, as the AI seems confused again and again.


-- 
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] Add ABI-fingerprint-based incremental compilation strategy [maven-compiler-plugin]

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


##########
src/main/java/org/apache/maven/plugin/compiler/incremental/BytecodeAnalyzer.java:
##########
@@ -0,0 +1,366 @@
+/*
+ * 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.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.MessageDigest;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+import java.util.TreeSet;
+
+import org.objectweb.asm.AnnotationVisitor;
+import org.objectweb.asm.ClassReader;
+import org.objectweb.asm.ClassVisitor;
+import org.objectweb.asm.FieldVisitor;
+import org.objectweb.asm.MethodVisitor;
+import org.objectweb.asm.Opcodes;
+import org.objectweb.asm.Type;
+
+/**
+ * Analyzes compiled {@code .class} files using ASM to extract type references
+ * and compute bytecode-level ABI fingerprints.
+ *
+ * <p>Type references are collected from the class hierarchy, field/method
+ * descriptors, exception types, annotations, and bytecode instructions
+ * ({@code new}, {@code checkcast}, {@code instanceof}, field/method owners).
+ *
+ * <p>The bytecode ABI reflects erased types (no generics) and is computed from
+ * non-private, non-synthetic fields and methods.
+ *
+ * @see AbiExtractor
+ * @see DependencyScanner
+ */
+public class BytecodeAnalyzer {
+
+    public record ClassAnalysis(
+            String className, String abiFingerprint, String abiCanonical, Set<String> referencedTypes) {}
+
+    public static ClassAnalysis analyze(Path classFile) throws IOException {
+        return analyze(Files.readAllBytes(classFile));
+    }
+
+    public static ClassAnalysis analyze(byte[] classBytes) {

Review Comment:
   can't we go with classfile api only (and disable if java is too old?), this will bite us often enough to care I think



-- 
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] Add ABI-fingerprint-based incremental compilation strategy [maven-compiler-plugin]

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


##########
pom.xml:
##########
@@ -192,6 +192,60 @@ under the License.
         </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.

Review Comment:
   Fixed in 9a5c56d. CI workflow now uses `ff-jdk: '25'` and `jdk-matrix: '["25"]'` to match the enforcer `[25,)` requirement. Dropped Microsoft distribution (no JDK 25 available) and removed the now-unnecessary matrix-exclude.



-- 
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] Add ABI-fingerprint-based incremental compilation strategy [maven-compiler-plugin]

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


##########
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:
   Acknowledged. This is a valid optimization — mtime-first check before hashing would reduce I/O for the common no-change case. Tracking as a follow-up improvement, not blocking for this PR.



##########
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. The over-cascading for multi-type CUs is safe (false recompilations, not missed ones). Acceptable trade-off for the initial implementation — can be refined later if profiling shows it matters.



-- 
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] Add ABI-fingerprint-based incremental compilation strategy [maven-compiler-plugin]

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


##########
src/main/java/org/apache/maven/plugin/compiler/ToolExecutor.java:
##########
@@ -915,6 +919,201 @@ 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 {
+        if (compiler instanceof ForkedTool) {
+            logger.warn("ABI incremental strategy is not supported with forked compilation."
+                    + " Falling back to full compilation.");
+        }
+        var abiBuild = new AbiIncrementalBuild(outputDirectory);

Review Comment:
   💡 **Suggestion (medium):** The warning says "Falling back to full compilation" but this isn't accurate on incremental builds. When state exists from a previous run, `initialize()` returns only changed files. Without `attachTo()` (since the forked task doesn't implement `JavacTask`), `processRound()` always returns `Set.of()` — so ABI cascade is silently skipped. Then `finish()` saves state with source hashes but no ABI fingerprints for newly compiled types.
   
   On the next build, those files' hashes match so they're skipped, but ABI data is incomplete — ABI-breaking changes in the forked-compiled files won't cascade to consumers.
   
   To truly fall back to full compilation, invalidate the state so `initialize()` returns all files:
   
   ```suggestion
           if (compiler instanceof ForkedTool) {
               logger.warn("ABI incremental strategy is not supported with forked compilation."
                       + " Falling back to full compilation.");
           }
           var abiBuild = new AbiIncrementalBuild(outputDirectory);
           if (compiler instanceof ForkedTool) {
               abiBuild.invalidate();
           }
   ```
   
   Alternatively, the `instanceof ForkedTool` check could short-circuit to the standard `compile()` method entirely, avoiding the ABI machinery altogether.



-- 
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] Add ABI-fingerprint-based incremental compilation strategy [maven-compiler-plugin]

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


##########
pom.xml:
##########
@@ -192,6 +192,34 @@ under the License.
         </plugin>
       </plugins>
     </pluginManagement>
+    <plugins>
+      <plugin>
+        <groupId>org.apache.maven.plugins</groupId>
+        <artifactId>maven-compiler-plugin</artifactId>
+        <executions>
+          <execution>
+            <!--
+              Compile sources that require java.lang.classfile (JDK 24+) separately
+              with release=24, so the main sources stay at the baseline Java version.
+              The compiled classes land alongside the main classes in the output directory.
+              BytecodeAnalyzer loads ClassfileClassAnalyzer reflectively at runtime when
+              the running JVM is JDK 24 or later; AsmClassAnalyzer is used otherwise.
+            -->
+            <id>compile-java24</id>
+            <goals>
+              <goal>compile</goal>
+            </goals>
+            <phase>compile</phase>
+            <configuration>
+              <compileSourceRoots>
+                <compileSourceRoot>${project.basedir}/src/main/java24</compileSourceRoot>
+              </compileSourceRoots>
+              <release>24</release>

Review Comment:
   Fixed in 950b860. Moved the `compile-java24` execution into a `java24+` profile with `<jdk>[24,)</jdk>` activation. On JDK < 24, the execution is skipped entirely.



##########
src/test/java/org/apache/maven/plugin/compiler/incremental/BytecodeAnalyzerTest.java:
##########
@@ -0,0 +1,180 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.plugin.compiler.incremental;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class BytecodeAnalyzerTest {
+
+    @TempDir
+    Path sourceDir;
+
+    @TempDir
+    Path outputDir;
+
+    @Test
+    void descriptorToReadableVoid() {
+        assertEquals("void", BytecodeAnalyzer.descriptorToReadable("V"));
+    }
+
+    @Test
+    void descriptorToReadableBoolean() {
+        assertEquals("boolean", BytecodeAnalyzer.descriptorToReadable("Z"));
+    }
+
+    @Test
+    void descriptorToReadableByte() {
+        assertEquals("byte", BytecodeAnalyzer.descriptorToReadable("B"));
+    }
+
+    @Test
+    void descriptorToReadableChar() {
+        assertEquals("char", BytecodeAnalyzer.descriptorToReadable("C"));
+    }
+
+    @Test
+    void descriptorToReadableShort() {
+        assertEquals("short", BytecodeAnalyzer.descriptorToReadable("S"));
+    }
+
+    @Test
+    void descriptorToReadableInt() {
+        assertEquals("int", BytecodeAnalyzer.descriptorToReadable("I"));
+    }
+
+    @Test
+    void descriptorToReadableLong() {
+        assertEquals("long", BytecodeAnalyzer.descriptorToReadable("J"));
+    }
+
+    @Test
+    void descriptorToReadableFloat() {
+        assertEquals("float", BytecodeAnalyzer.descriptorToReadable("F"));
+    }
+
+    @Test
+    void descriptorToReadableDouble() {
+        assertEquals("double", BytecodeAnalyzer.descriptorToReadable("D"));
+    }
+
+    @Test
+    void descriptorToReadableObject() {
+        assertEquals("java.lang.String", BytecodeAnalyzer.descriptorToReadable("Ljava/lang/String;"));
+    }
+
+    @Test
+    void descriptorToReadableArray() {
+        assertEquals("int[]", BytecodeAnalyzer.descriptorToReadable("[I"));
+        assertEquals("java.lang.String[]", BytecodeAnalyzer.descriptorToReadable("[Ljava/lang/String;"));
+    }
+
+    @Test
+    void descriptorToReadableEmpty() {
+        assertEquals("", BytecodeAnalyzer.descriptorToReadable(""));
+    }
+
+    @Test
+    void parseParamsMultiple() {
+        assertEquals("java.lang.String, int", BytecodeAnalyzer.parseParams("(Ljava/lang/String;I)V"));
+    }
+
+    @Test
+    void parseParamsEmpty() {
+        assertEquals("", BytecodeAnalyzer.parseParams("()V"));
+    }
+
+    @Test
+    void parseReturnType() {
+        assertEquals("java.lang.String", BytecodeAnalyzer.parseReturn("(I)Ljava/lang/String;"));
+        assertEquals("void", BytecodeAnalyzer.parseReturn("()V"));
+        assertEquals("int", BytecodeAnalyzer.parseReturn("()I"));
+    }
+
+    @Test
+    void analyzePathProducesCorrectClassName() throws Exception {
+        CompilerTestHelper.writeSource(
+                sourceDir,
+                "test",
+                "Hello",
+                "package test; public class Hello { public String greet() { return \"hi\"; } }");
+        CompilerTestHelper.compileAndAnalyze(sourceDir, outputDir);
+
+        Path classFile = outputDir.resolve("test/Hello.class");
+        assertTrue(Files.exists(classFile));
+
+        var analysis = BytecodeAnalyzer.analyze(classFile);
+        assertEquals("test.Hello", analysis.className());
+        assertNotNull(analysis.abiFingerprint());
+        assertNotNull(analysis.abiCanonical());
+    }
+
+    @Test
+    void analyzeBytesEquivalentToAnalyzePath() throws Exception {
+        CompilerTestHelper.writeSource(
+                sourceDir, "test", "Hello", "package test; public class Hello { public int value() { return 42; } }");
+        CompilerTestHelper.compileAndAnalyze(sourceDir, outputDir);
+
+        Path classFile = outputDir.resolve("test/Hello.class");
+        var fromPath = BytecodeAnalyzer.analyze(classFile);
+        var fromBytes = BytecodeAnalyzer.analyze(Files.readAllBytes(classFile));
+
+        assertEquals(fromPath.className(), fromBytes.className());
+        assertEquals(fromPath.abiFingerprint(), fromBytes.abiFingerprint());
+        assertEquals(fromPath.abiCanonical(), fromBytes.abiCanonical());
+        assertEquals(fromPath.referencedTypes(), fromBytes.referencedTypes());
+    }
+
+    /**
+     * When running on JDK 24+, {@link BytecodeAnalyzer} uses {@link ClassfileClassAnalyzer}
+     * instead of {@link AsmClassAnalyzer}. Both must produce identical results for the same
+     * class file. This test cross-validates the two implementations against each other.
+     */
+    @Test
+    void asmAndClassfileAnalyzersProduceIdenticalResults() throws Exception {
+        CompilerTestHelper.writeSource(sourceDir, "test", "Subject", """
+                package test;
+                public class Subject implements Runnable {
+                    public static final String CONST = "hello";
+                    private int secret = 42;
+                    public String greet(int x, java.util.List<String> items) { return CONST + x; }
+                    @Override public void run() { greet(1, null); }
+                    protected static int helper(byte b) throws java.io.IOException { return b; }
+                }
+                """);
+        CompilerTestHelper.compileAndAnalyze(sourceDir, outputDir);
+
+        byte[] bytes = Files.readAllBytes(outputDir.resolve("test/Subject.class"));
+
+        var asmResult = new AsmClassAnalyzer().analyze(bytes);
+        var cfResult = new ClassfileClassAnalyzer().analyze(bytes);

Review Comment:
   Fixed in 950b860. Added `@EnabledForJreRange(min = JAVA_24)` and switched to reflective instantiation of `ClassfileClassAnalyzer` — same pattern as `BytecodeAnalyzer.selectAnalyzer()` — to avoid a compile-time dependency on the JDK 24+ class.



-- 
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] refactor: simplify annotation processor incremental handling [maven-compiler-plugin]

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


##########
src/main/java/org/apache/maven/plugin/compiler/AbstractCompilerMojo.java:
##########
@@ -1399,6 +1416,16 @@ public Options parseParameters(final OptionChecker compiler) {
     @SuppressWarnings("UseSpecificCatch")
     private void compile(final JavaCompiler compiler, final Options configuration) throws IOException {
         final ToolExecutor executor = createExecutor(null);
+        if ("abi".equalsIgnoreCase(incrementalStrategy) && !Boolean.FALSE.equals(useIncrementalCompilation)) {
+            if (!org.apache.maven.plugin.compiler.incremental.BytecodeAnalyzer.isAvailable()) {
+                logger.warn("ABI incremental strategy requires JDK 24 or later "
+                        + "(running JDK " + Runtime.version().feature() + "). "
+                        + "Falling back to timestamp strategy.");
+            } else {
+                executor.compileWithAbiIncremental(compiler, configuration, this);

Review Comment:
   🔴 **Critical: method name mismatch — compilation error.** `ToolExecutor` defines `compileWithGraphIncremental()` (not `compileWithAbiIncremental()`). This call will fail to compile. Either rename the call site to match the definition, or rename the method to `compileWithAbiIncremental()` (and update `ToolExecutorForTest` accordingly).



##########
src/main/java/org/apache/maven/plugin/compiler/incremental/GraphIncrementalBuild.java:
##########
@@ -0,0 +1,947 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.plugin.compiler.incremental;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeSet;
+import java.util.stream.Collectors;
+
+/**
+ * Dependency-graph-driven incremental build engine, designed for embedding
+ * in maven-compiler-plugin alongside the existing timestamp-based
+ * {@code IncrementalBuild}.
+ *
+ * <p>The plugin drives compilation; this class determines <em>what</em> to
+ * compile and performs post-compilation bytecode analysis to build the
+ * class-level dependency graph. Typical usage:
+ *
+ * {@snippet :
+ * var build = new GraphIncrementalBuild(outputDir);
+ * build.setProcessorPath(processorPath);
+ * build.setConfigHash(configHash);
+ *
+ * Set<Path> toCompile = build.initialize(allSourceFiles);
+ *
+ * while (!toCompile.isEmpty()) {
+ *     compiler.compile(toCompile);      // any compiler, any mode
+ *     toCompile = build.processCompiledClasses(toCompile);
+ * }
+ *
+ * build.finish();
+ * }
+ *
+ * <p>After each compilation pass, {@link #processCompiledClasses(Set)} scans
+ * the freshly produced {@code .class} files, updates the dependency graph,
+ * and returns any additional source files that must be compiled in the next
+ * pass (cascade due to changed classes, or newly discovered dependencies).
+ * The loop converges in at most 2–3 passes in practice.
+ *
+ * <p>The engine persists its state as {@code incremental-state} in the
+ * {@code target/maven-status/maven-compiler-plugin/<outputDirName>/} directory (outside the class
+ * output directory so it is not packaged into JARs).
+ *
+ * @see IncrementalState
+ */
+public class GraphIncrementalBuild {
+
+    /** Prefix used to distinguish module-info entries from regular type entries in the state. */
+    static final String MODULE_PREFIX = BytecodeAnalyzer.MODULE_PREFIX;
+
+    private final Path outputDir;
+    private final Path buildDir;
+    private final Path stateFile;
+    private List<Path> classpathEntries;
+    private Set<Path> reactorModulePaths;
+    private boolean abiTracking;
+    private List<Path> processorPath;
+    private ProcessorClassification processorClassification;
+
+    private IncrementalState previousState;
+    private IncrementalState state;
+
+    /** Package-private accessor for tests. */
+    IncrementalState getState() {
+        return state;
+    }
+
+    private Map<String, String> sourceHashes;
+    private Map<String, Long> sourceMtimes;
+    private List<Path> allSourceFiles;
+    private Set<String> allCompiled;
+    private boolean fullBuild;
+    private boolean useModulePrefixedPaths;
+    private String configHash = "";
+    private String rebuildCause;
+    private int totalSources;
+    /** Lazily populated on full builds; maps each output class file to its simple top-level class name. */
+    private Map<Path, String> outputClassIndex;
+
+    public GraphIncrementalBuild(Path outputDir) {
+        this.outputDir = outputDir.toAbsolutePath();
+        this.buildDir = this.outputDir.getParent() != null ? this.outputDir.getParent() : this.outputDir;
+        // Store state outside the output directory so it is not included in the JAR.
+        // Use the same maven-status convention as the timestamp-based strategy.
+        // Include the output directory name (e.g. "classes", "test-classes") to avoid
+        // collisions between compile and testCompile executions.
+        String outputDirName = outputDir.getFileName().toString();
+        Path mavenStatus = buildDir.resolve("maven-status").resolve("maven-compiler-plugin");
+        this.stateFile = mavenStatus.resolve(outputDirName).resolve("incremental-state");
+    }
+
+    /**
+     * Sets classpath entries for 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;
+    }
+
+    /**
+     * Enables ABI tracking mode ({@code abi} strategy). When {@code true}, the
+     * engine computes ABI fingerprints and fine-grained {@code signatureDeps}/
+     * {@code implementationDeps} for each compiled type, and writes an
+     * {@link AbiManifest} on {@link #finish()}. When {@code false} (default,
+     * {@code graph} strategy), only class-level dependency references are collected.
+     */
+    public void setAbiTracking(boolean abiTracking) {
+        this.abiTracking = abiTracking;
+    }
+
+    /**
+     * Sets the annotation processor classpath for processor classification.
+     * Entries are scanned for {@code META-INF/maven/compiler/incremental.annotation.processors}
+     * and {@code META-INF/gradle/incremental.annotation.processors} to determine
+     * whether each processor is {@link ProcessorType#ISOLATING},
+     * {@link ProcessorType#AGGREGATING}, or {@link ProcessorType#UNKNOWN}.
+     */
+    public void setProcessorPath(List<Path> processorPath) {
+        this.processorPath = processorPath;
+        this.processorClassification = new ProcessorClassification(processorPath);
+    }
+
+    /**
+     * Indicates that class files are written under module-name subdirectories
+     * of the output directory (MODULE_SOURCE hierarchy). When set, stored module
+     * names are used as path prefixes when deleting class files.
+     */
+    public void setUseModulePrefixedPaths(boolean useModulePrefixedPaths) {
+        this.useModulePrefixedPaths = useModulePrefixedPaths;
+    }
+
+    /**
+     * Sets a hash of compilation context configuration (e.g. compiler options
+     * and module-info-patch files). If this hash differs from the previous
+     * build, a full rebuild is triggered.
+     */
+    public void setConfigHash(String hash) {
+        this.configHash = hash != null ? hash : "";
+    }
+
+    /**
+     * Initializes the incremental build by scanning source files and comparing
+     * against the previous build's state.
+     *
+     * @param allSourceFiles all source files in this module
+     * @return the set of files that need compilation (may be all files for a
+     *         full build, a subset for incremental, or empty if up-to-date)
+     */
+    public Set<Path> initialize(List<Path> allSourceFiles) throws IOException {
+        Files.createDirectories(outputDir);
+
+        this.allSourceFiles = allSourceFiles;
+        totalSources = allSourceFiles.size();
+        allCompiled = new TreeSet<>();
+        previousState = IncrementalState.load(stateFile);
+        sourceMtimes = new LinkedHashMap<>();
+        sourceHashes = hashSourceFiles(allSourceFiles, previousState, sourceMtimes);
+
+        if (previousState == null) {
+            rebuildCause = "no previous build state";
+            return initFullBuild(allSourceFiles);
+        } else if (!configHash.equals(previousState.getConfigHash())) {
+            rebuildCause = "compilation configuration changed (module-info-patch.maven or compiler options)";
+            return initFullBuild(allSourceFiles);
+        } else {
+            return initIncrementalBuild(allSourceFiles);
+        }
+    }
+
+    /**
+     * Processes the {@code .class} files produced by the last compilation pass.
+     * Scans each class file to extract the dependency graph, records changes,
+     * and returns any additional source files that must be compiled in the next pass.
+     *
+     * <p>The cascade logic: any compiled class whose content changed (new or modified)
+     * triggers recompilation of all source files that depend on it (signature or
+     * implementation consumers).
+     *
+     * @param compiledSourceFiles the source files that were passed to the compiler in this round
+     * @return additional source files to compile (may be empty when fixpoint is reached)
+     * @throws IOException if reading {@code .class} files fails
+     */
+    public Set<Path> processCompiledClasses(Set<Path> compiledSourceFiles) throws IOException {
+        // Reset the class index so it is rebuilt fresh for each compilation round
+        outputClassIndex = null;
+
+        // Scan .class files for the types produced from the compiled source files
+        var results = new LinkedHashMap<String, SourceFileAnalysis>();
+        for (Path sourceFile : compiledSourceFiles) {
+            collectClassAnalyses(sourceFile, results);
+        }
+
+        // All compiled types cascade to their consumers (any change triggers recompilation)
+        var changedTypes = new TreeSet<>(results.keySet());

Review Comment:
   🟠 **Medium: ABI fingerprint comparison is missing — cascade is always maximal.** `changedTypes` is initialized to `results.keySet()` (all compiled types) unconditionally. The ABI manifest is computed and stored in `IncrementalState.AbiTypeInfo` but is never compared against the previous round's manifest to filter which types actually changed their public API. This means the `abi` strategy cascades all compiled types on every incremental build — no better than graph without ABI filtering. The filtering logic that would compare `newAbi != oldAbi` before adding to `changedTypes` is absent.



##########
src/it/abi-incremental-cascade/pom.xml:
##########
@@ -0,0 +1,67 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+  ~ Licensed to the Apache Software Foundation (ASF) under one
+  ~ or more contributor license agreements.  See the NOTICE file
+  ~ distributed with this work for additional information
+  ~ regarding copyright ownership.  The ASF licenses this file
+  ~ to you under the Apache License, Version 2.0 (the
+  ~ "License"); you may not use this file except in compliance
+  ~ with the License.  You may obtain a copy of the License at
+  ~
+  ~   http://www.apache.org/licenses/LICENSE-2.0
+  ~
+  ~ Unless required by applicable law or agreed to in writing,
+  ~ software distributed under the License is distributed on an
+  ~ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+  ~ KIND, either express or implied.  See the License for the
+  ~ specific language governing permissions and limitations
+  ~ under the License.
+  -->
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
+  <modelVersion>4.0.0</modelVersion>
+
+  <groupId>org.apache.maven.plugins.compiler.it</groupId>
+  <artifactId>abi-incremental-cascade</artifactId>
+  <version>1.0-SNAPSHOT</version>
+
+  <description>IT: ABI change should cascade to consumers (recompile Service when Model API changes).</description>
+
+  <properties>
+    <maven.compiler.release>17</maven.compiler.release>
+    <maven.compiler.incrementalStrategy>graph</maven.compiler.incrementalStrategy>

Review Comment:
   🔴 **Critical: `incrementalStrategy=graph` has no dispatch path.** The `compile()` method in `AbstractCompilerMojo` only checks `"abi".equalsIgnoreCase(incrementalStrategy)` — a value of `"graph"` falls through to the timestamp strategy. Both `verify.groovy` scripts assert `'graph: full build'` and `'graph: incremental'`/`'graph cascade: recompiling'`, which are emitted by `GraphIncrementalBuild` — but with `graph` strategy falling through to timestamp, those log messages will never appear. Both ITs will fail.
   
   Either add a `graph` dispatch branch, or change the IT strategy to `abi` if the intent was to test ABI tracking.



-- 
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] Add ABI-fingerprint-based incremental compilation strategy [maven-compiler-plugin]

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


##########
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 system property
+              so it is resolved before target/classes/ by the bootstrap class loader.
+            -->
+            <classesDirectory>${project.build.outputDirectory}/META-INF/versions/24</classesDirectory>
+            <additionalClasspathElements>

Review Comment:
   💡 **Nit (low):** The comment says "We prepend META-INF/versions/24/ via the system property" — `<classesDirectory>` is a Surefire plugin configuration parameter, not a system property. Minor wording inaccuracy.
   
   ```suggestion
                 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 set classesDirectory to META-INF/versions/24/ and add
                 target/classes as an additional classpath element, so the JDK 24+ version
                 is resolved first.
   ```



-- 
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] Add ABI-fingerprint-based incremental compilation strategy [maven-compiler-plugin]

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


##########
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 system property
+              so it is resolved before target/classes/ by the bootstrap class loader.

Review Comment:
   💡 **Still unaddressed from previous review (nit):** `<classesDirectory>` is a Surefire plugin configuration parameter, not a "system property." Minor wording inaccuracy.
   
   ```suggestion
                 on the classpath. We prepend META-INF/versions/24/ via the classesDirectory parameter
   ```



##########
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,
+     * 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>();
+        var reactorPaths = new LinkedHashSet<Path>();
+        for (var entry : dependencies.entrySet()) {
+            if (entry.getKey() instanceof JavaPathType type) {
+                var location = type.location();
+                if (location.isPresent()
+                        && (location.get() == StandardLocation.CLASS_PATH
+                                || location.get() == StandardLocation.MODULE_PATH)) {
+                    classpathPaths.addAll(entry.getValue());
+                    if (location.get() == StandardLocation.MODULE_PATH) {
+                        for (Path p : entry.getValue()) {
+                            if (Files.isDirectory(p)) {
+                                reactorPaths.add(p);
+                            }
+                        }
+                    }
+                }
+            }
+        }
+        abiBuild.setClasspathEntries(classpathPaths);
+        if (!reactorPaths.isEmpty()) {
+            abiBuild.setReactorModulePaths(reactorPaths);
+        }
+
+        // Collect annotation processor path for processor classification
+        var processorPaths = new ArrayList<Path>();
+        for (var entry : dependencies.entrySet()) {
+            if (entry.getKey() instanceof JavaPathType type) {
+                var location = type.location();
+                if (location.isPresent()
+                        && (location.get() == StandardLocation.ANNOTATION_PROCESSOR_PATH
+                                || location.get() == StandardLocation.ANNOTATION_PROCESSOR_MODULE_PATH)) {
+                    processorPaths.addAll(entry.getValue());
+                }
+            }
+        }
+        if (!processorPaths.isEmpty()) {
+            abiBuild.setProcessorPath(processorPaths);
+        }
+
+        // Hash module-info-patch.maven files for config change detection
+        abiBuild.setConfigHash(computeConfigHash(configuration));
+
+        // 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).");
+        if (mojo.showCompilationChanges && abiBuild.getRebuildCause() != null) {
+            logger.info("Rebuild cause: " + abiBuild.getRebuildCause());
+            for (Path f : toCompile) {
+                logger.info("  " + f);
+            }
+        }
+
+        var originalSourceFiles = new ArrayList<>(sourceFiles);
+        boolean success = true;
+        // Safety bound: the compile set is monotonically growing (bounded by total source count).
+        // If a bug causes processCompiledClasses to return files already compiled, this prevents
+        // an infinite loop. In practice this limit should never be reached.
+        int maxRounds = originalSourceFiles.size() + 1;
+        int rounds = 0;
+
+        try {
+            while (!toCompile.isEmpty()) {
+                if (++rounds > maxRounds) {
+                    throw new IllegalStateException("ABI cascade loop did not converge after " + maxRounds
+                            + " rounds — " + "possible dependency cycle or bug in processCompiledClasses()");
+                }
+                Set<Path> compileSet = toCompile;
+                sourceFiles = originalSourceFiles.stream()
+                        .filter(sf -> compileSet.contains(sf.file))
+                        .collect(Collectors.toList());
+
+                if (sourceFiles.isEmpty()) {
+                    break;
+                }
+
+                var compilerOutput = new StringWriter();
+                success = compileWithAbiAnalyzer(compiler, configuration, compilerOutput, abiBuild);
+                String output = compilerOutput.toString();
+                if (!output.isBlank()) {
+                    logger.warn(output);
+                }
+                if (!success) {
+                    break;
+                }
+
+                toCompile = abiBuild.processCompiledClasses(compileSet);
+                if (!toCompile.isEmpty()) {
+                    logger.info("ABI cascade: recompiling " + toCompile.size() + " additional file(s).");
+                    if (mojo.showCompilationChanges) {
+                        for (Path f : toCompile) {
+                            logger.info("  " + f);
+                        }
+                    }
+                }
+            }
+        } finally {
+            sourceFiles = originalSourceFiles;
+        }
+
+        if (success) {
+            abiBuild.finish();
+            logger.info("Compiled " + abiBuild.compiledCount() + " file(s), " + abiBuild.unchangedCount()
+                    + " unchanged (ABI strategy).");
+        } else {
+            abiBuild.invalidate();
+            throw new CompilationFailureException("Compilation failed (ABI incremental strategy).");
+        }
+    }
+
+    private String computeConfigHash(Options configuration) {
+        var digest = new StringBuilder();
+
+        // Include compiler options in the config hash so changes to -source/-target/-release
+        // etc. trigger a full rebuild under the ABI strategy.
+        String optionsRepr = String.join("|", configuration.options);
+        digest.append("opts:").append(optionsRepr).append(';');
+
+        for (SourceDirectory source : sourceDirectories) {
+            Path patchFile = source.root.resolve(ModuleInfoPatch.FILENAME);
+            if (Files.isRegularFile(patchFile)) {
+                try {
+                    byte[] content = Files.readAllBytes(patchFile);
+                    digest.append(patchFile)
+                            .append(':')
+                            .append(Sha256.hash(content))
+                            .append(';');
+                } catch (IOException e) {
+                    digest.append(patchFile).append(":unreadable;");
+                }
+            }
+        }
+        if (digest.isEmpty()) {
+            return "";
+        }
+        return Sha256.hash(digest.toString());
+    }
+
+    /**
+     * Compiles sources with the ABI analyzer attached as a TaskListener.

Review Comment:
   💡 **Still unaddressed from previous review:** This Javadoc still says "Compiles sources with the ABI analyzer attached as a TaskListener" — the TaskListener approach was removed. The method now just compiles; bytecode analysis happens in `processCompiledClasses()` after this returns.
   
   ```suggestion
        * Compiles sources and performs ABI analysis on the compiled classes.
   ```



-- 
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] Add ABI-fingerprint-based incremental compilation strategy [maven-compiler-plugin]

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


##########
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:
+ *
+ * <pre>{@code

Review Comment:
   Replace `<pre>{@code` by `{@snippet`



-- 
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] Add ABI-fingerprint-based incremental compilation strategy [maven-compiler-plugin]

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


##########
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:
+ *
+ * <pre>{@code
+ * 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();
+ * }</pre>
+ *
+ * <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

Review Comment:
   Why a hidden file? (the `.` prefix).



-- 
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] Add ABI-fingerprint-based incremental compilation strategy [maven-compiler-plugin]

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


##########
src/main/java/org/apache/maven/plugin/compiler/incremental/ClassAnalyzer.java:
##########
@@ -0,0 +1,250 @@
+/*
+ * 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.Files;
+import java.nio.file.Path;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Abstract base for analyzing compiled {@code .class} files.
+ *
+ * <p>The single built-in implementation is:
+ * <ul>
+ *   <li>{@code ClassfileClassAnalyzer} — uses the standard
+ *       {@code java.lang.classfile} API introduced in JDK 24; loaded
+ *       reflectively at runtime. Requires JDK 24 or later.</li>
+ * </ul>
+ *
+ * <p>Use {@link BytecodeAnalyzer#analyze(byte[])} which selects the best
+ * available implementation automatically.
+ *
+ * @see BytecodeAnalyzer
+ */
+public abstract class ClassAnalyzer {
+
+    // JVM spec access flag constants — shared by both implementations
+    protected static final int ACC_PUBLIC = 0x0001;
+    protected static final int ACC_PRIVATE = 0x0002;
+    protected static final int ACC_PROTECTED = 0x0004;
+    protected static final int ACC_STATIC = 0x0008;
+    protected static final int ACC_FINAL = 0x0010;
+    protected static final int ACC_INTERFACE = 0x0200;
+    protected static final int ACC_ABSTRACT = 0x0400;
+    protected static final int ACC_SYNTHETIC = 0x1000;
+    protected static final int ACC_ENUM = 0x4000;
+
+    protected static final Set<String> EXCLUDED_SUPERTYPES =
+            Set.of("java.lang.Object", "java.lang.Enum", "java.lang.Record");
+
+    /**
+     * A non-private, non-synthetic field extracted from a class file.
+     *
+     * @param access        JVM access flags bitmask
+     * @param name          field name
+     * @param descriptor    JVM type descriptor
+     * @param constantValue compile-time constant value, or {@code null}
+     * @param signature     generic signature from the Signature attribute, or {@code null}
+     */
+    protected record FieldInfo(int access, String name, String descriptor, Object constantValue, String signature)
+            implements Comparable<FieldInfo> {
+        @Override
+        public int compareTo(FieldInfo o) {
+            return name.compareTo(o.name);
+        }
+    }
+
+    /**
+     * A non-private, non-synthetic method extracted from a class file.
+     *
+     * @param access     JVM access flags bitmask
+     * @param name       method name
+     * @param descriptor JVM method descriptor
+     * @param signature  generic signature from the Signature attribute, or {@code null}
+     */
+    protected record MethodInfo(int access, String name, String descriptor, String signature)
+            implements Comparable<MethodInfo> {
+        @Override
+        public int compareTo(MethodInfo o) {
+            int c = name.compareTo(o.name);
+            return c != 0 ? c : descriptor.compareTo(o.descriptor);
+        }
+    }
+
+    /**
+     * Analyzes the given class file bytes.
+     *
+     * @param classBytes raw {@code .class} file content
+     * @return analysis result containing class name, ABI fingerprint, canonical
+     *         form, and referenced type names
+     */
+    public abstract BytecodeAnalyzer.ClassAnalysis analyze(byte[] classBytes);
+
+    /**
+     * Analyzes the class file at the given path.
+     *
+     * @param classFile path to the {@code .class} file
+     * @return analysis result
+     * @throws IOException if reading the file fails
+     */
+    public BytecodeAnalyzer.ClassAnalysis analyze(Path classFile) throws IOException {
+        return analyze(Files.readAllBytes(classFile));
+    }
+
+    /**
+     * Builds the ABI canonical form from extracted class metadata.
+     * The format is deterministic and identical across both analyzer
+     * implementations, ensuring consistent fingerprints.
+     *
+     * @param classAccess    JVM access flags for the class
+     * @param className      fully-qualified class name (dot-separated)
+     * @param classSignature generic signature of the class, or {@code null}
+     * @param superName      fully-qualified superclass name, or {@code null}
+     * @param interfaces     fully-qualified interface names (dot-separated)
+     * @param fields         non-private, non-synthetic fields (will be sorted)
+     * @param methods        non-private, non-synthetic methods (will be sorted)
+     * @return the canonical ABI string
+     */
+    protected static String buildCanonicalForm(
+            int classAccess,
+            String className,
+            String classSignature,
+            String superName,
+            List<String> interfaces,
+            List<FieldInfo> fields,
+            List<MethodInfo> methods) {
+
+        Collections.sort(fields);
+        Collections.sort(methods);
+
+        var sb = new StringBuilder();
+
+        appendAccessFlags(sb, classAccess);
+        if ((classAccess & ACC_INTERFACE) != 0) {
+            sb.append("interface ");
+        } else if ((classAccess & ACC_ENUM) != 0) {
+            sb.append("enum ");
+        } else {
+            sb.append("class ");
+        }
+        sb.append(className);
+        if (classSignature != null) {
+            sb.append(" <sig: ").append(classSignature).append('>');
+        }
+        sb.append('\n');
+
+        if (superName != null && !EXCLUDED_SUPERTYPES.contains(superName)) {
+            sb.append("  extends ").append(superName).append('\n');
+        }
+
+        for (String iface : interfaces) {
+            sb.append("  implements ").append(iface).append('\n');
+        }
+
+        for (var f : fields) {
+            sb.append("  ");
+            appendAccessFlags(sb, f.access);
+            sb.append(BytecodeAnalyzer.descriptorToReadable(f.descriptor)).append(' ');
+            sb.append(f.name);
+            if (f.constantValue != null) {
+                sb.append(" = ").append(f.constantValue);
+            }
+            if (f.signature != null) {
+                sb.append(" <sig: ").append(f.signature).append('>');
+            }
+            sb.append('\n');
+        }
+
+        for (var m : methods) {
+            sb.append("  ");
+            appendAccessFlags(sb, m.access);
+            sb.append(m.name);
+            sb.append('(').append(BytecodeAnalyzer.parseParams(m.descriptor)).append(')');
+            String ret = BytecodeAnalyzer.parseReturn(m.descriptor);
+            if (!"void".equals(ret)) {
+                sb.append(" -> ").append(ret);
+            }
+            if (m.signature != null) {
+                sb.append(" <sig: ").append(m.signature).append('>');
+            }
+            sb.append('\n');
+        }
+
+        return sb.toString();
+    }
+
+    /**
+     * Appends the human-readable access flag names (public, protected, abstract,
+     * static, final) to the builder, in the canonical order.
+     */
+    protected static void appendAccessFlags(StringBuilder sb, int access) {

Review Comment:
   Just store the `int` flag in binary, no need to make it human-readable.



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