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: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to