From 6909af8727d60cde7247ae5a3cc01e70bf67b356 Mon Sep 17 00:00:00 2001 From: Kamil Patryk Kozakowski Date: Fri, 17 Jul 2026 09:16:27 +0200 Subject: [PATCH] C --- .../service/AbstractCallGraphEngine.java | 39 ++-- .../service/EntryPointBindingExpander.java | 19 +- .../analysis/service/TypeResolver.java | 29 ++- .../ast/common/CodebaseContext.java | 167 +++++++++++++----- .../ast/common/CodebaseContextTest.java | 138 +++++++++++++++ 5 files changed, 313 insertions(+), 79 deletions(-) diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AbstractCallGraphEngine.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AbstractCallGraphEngine.java index 88db217..13b58a7 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AbstractCallGraphEngine.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AbstractCallGraphEngine.java @@ -1591,21 +1591,21 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { } TypeDeclaration enclosingType = findEnclosingType(receiverNameNode); - TypeDeclaration currentType = enclosingType; - while (currentType != null) { + final String[] resolvedType = {null}; + context.forEachSuperclass(enclosingType, currentType -> { + if (resolvedType[0] != null) { + return; + } for (FieldDeclaration field : currentType.getFields()) { for (Object fragObj : field.fragments()) { VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj; if (frag.getName().getIdentifier().equals(varName)) { - return resolveTypeToFqn(field.getType(), receiverNameNode); + resolvedType[0] = resolveTypeToFqn(field.getType(), receiverNameNode); } } } - String superclass = context.getSuperclassFqn(currentType); - currentType = superclass != null ? context.getTypeDeclaration(superclass) : null; - } - - return null; + }); + return resolvedType[0]; } protected String resolveTypeToFqn(Type type, ASTNode contextNode) { @@ -1820,19 +1820,18 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { return resolved; } String superFqn = context.getSuperclassFqn(td); - while (superFqn != null) { - TypeDeclaration superTd = context.getTypeDeclaration(superFqn); - if (superTd != null) { - resolved = resolveMethodInType(superTd, methodName); - if (resolved != null) { - return resolved; - } - superFqn = context.getSuperclassFqn(superTd); - } else { - break; - } + TypeDeclaration superTd = superFqn != null ? context.getTypeDeclaration(superFqn) : null; + if (superTd == null) { + return null; } - return null; + final String[] found = {null}; + context.forEachSuperclass(superTd, current -> { + if (found[0] != null) { + return; + } + found[0] = resolveMethodInType(current, methodName); + }); + return found[0]; } protected Expression traceVariable(Expression expr) { diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/EntryPointBindingExpander.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/EntryPointBindingExpander.java index 2ae75ff..27f447d 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/EntryPointBindingExpander.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/EntryPointBindingExpander.java @@ -788,11 +788,18 @@ public final class EntryPointBindingExpander { TypeDeclaration typeDecl, String fieldName, CodebaseContext context) { - for (TypeDeclaration current = typeDecl; current != null; current = superTypeDeclaration(current, context)) { + final Map[] found = new Map[1]; + context.forEachSuperclass(typeDecl, current -> { + if (found[0] != null) { + return; + } Map entries = extractStaticFieldMapInitializerOnType(current, fieldName, context); if (entries != null) { - return entries; + found[0] = entries; } + }); + if (found[0] != null) { + return found[0]; } return extractStaticFieldMapInitializerFromInterfaces(typeDecl, fieldName, context, new LinkedHashSet<>()); } @@ -835,14 +842,6 @@ public final class EntryPointBindingExpander { return null; } - private static TypeDeclaration superTypeDeclaration(TypeDeclaration typeDecl, CodebaseContext context) { - String superFqn = context.getSuperclassFqn(typeDecl); - if (superFqn == null || "java.lang.Object".equals(superFqn)) { - return null; - } - return context.getTypeDeclaration(superFqn); - } - private static Map extractStaticFieldMapInitializerOnType( TypeDeclaration typeDecl, String fieldName, diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/TypeResolver.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/TypeResolver.java index f040194..9a9c86f 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/TypeResolver.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/TypeResolver.java @@ -280,13 +280,30 @@ public class TypeResolver { if (td != null) { if (td instanceof TypeDeclaration typeDecl) { String superFqn = context.getSuperclassFqn(typeDecl); - while (superFqn != null) { - if (superFqn.contains("<")) { - superFqn = superFqn.substring(0, superFqn.indexOf('<')); + TypeDeclaration superTd = superFqn != null ? context.getTypeDeclaration(superFqn) : null; + if (superTd != null) { + final String normalizedExpectedType = expectedType; + final boolean[] matched = {false}; + context.forEachSuperclass(superTd, current -> { + if (matched[0]) { + return; + } + String currentFqn = context.getFqn(current); + if (currentFqn == null) { + return; + } + if (currentFqn.contains("<")) { + currentFqn = currentFqn.substring(0, currentFqn.indexOf('<')); + } + if (currentFqn.equals(normalizedExpectedType) + || currentFqn.endsWith("." + normalizedExpectedType) + || normalizedExpectedType.endsWith("." + currentFqn)) { + matched[0] = true; + } + }); + if (matched[0]) { + return true; } - if (superFqn.equals(expectedType) || superFqn.endsWith("." + expectedType) || expectedType.endsWith("." + superFqn)) return true; - TypeDeclaration superTd = context.getTypeDeclaration(superFqn); - superFqn = superTd != null ? context.getSuperclassFqn(superTd) : null; } } List interfaces = java.util.Collections.emptyList(); diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/CodebaseContext.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/CodebaseContext.java index 232ae2c..4545ae3 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/CodebaseContext.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/CodebaseContext.java @@ -35,6 +35,7 @@ import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.function.Consumer; import lombok.extern.slf4j.Slf4j; @Slf4j @@ -378,18 +379,12 @@ public class CodebaseContext { private void indexType(TypeDeclaration td, String parentFqn, Path javaFile) { String simpleName = td.getName().getIdentifier(); String fqn = parentFqn.isEmpty() ? simpleName : parentFqn + "." + simpleName; - - classes.put(fqn, (CompilationUnit) td.getRoot()); - classPaths.put(fqn, javaFile); - typeDeclarations.put(fqn, td); - if (simpleNameToFqn.containsKey(simpleName)) { - String existingFqn = simpleNameToFqn.get(simpleName); - if (!existingFqn.equals(fqn)) { - ambiguousSimpleNames.add(simpleName); - } - } else { - simpleNameToFqn.put(simpleName, fqn); + if (shouldReplaceTypeIndex(fqn, javaFile)) { + classes.put(fqn, (CompilationUnit) td.getRoot()); + classPaths.put(fqn, javaFile); + typeDeclarations.put(fqn, td); + registerSimpleNameForFqn(simpleName, fqn); } // Recursively index nested types @@ -452,23 +447,93 @@ public class CodebaseContext { return raw; } - private void indexRecord(org.eclipse.jdt.core.dom.RecordDeclaration rd, String parentFqn, Path javaFile) { - String simpleName = rd.getName().getIdentifier(); - String fqn = parentFqn.isEmpty() ? simpleName : parentFqn + "." + simpleName; - - // Treat as a class since it is a type - classes.put(fqn, (CompilationUnit) rd.getRoot()); - classPaths.put(fqn, javaFile); - recordDeclarations.put(fqn, rd); + /** + * Higher rank wins on duplicate FQN indexing: main sources beat build artifacts. + */ + static int sourcePathRank(Path path) { + if (path == null) { + return 0; + } + String normalized = path.toString().replace('\\', '/'); + if (normalized.contains("/src/main/java/")) { + return 2; + } + if (normalized.contains("/target/") || normalized.contains("/build/")) { + return 0; + } + return 1; + } + private boolean shouldReplaceTypeIndex(String fqn, Path javaFile) { + Path existingPath = classPaths.get(fqn); + if (existingPath == null) { + return true; + } + int existingRank = sourcePathRank(existingPath); + int newRank = sourcePathRank(javaFile); + if (newRank > existingRank) { + return true; + } + if (newRank < existingRank) { + if (log.isDebugEnabled()) { + log.debug( + "Skipping lower-rank duplicate type index fqn={} path={} rank={} kept={} rank={}", + fqn, + javaFile, + newRank, + existingPath, + existingRank); + } + return false; + } + return false; + } + + private void registerSimpleNameForFqn(String simpleName, String fqn) { if (simpleNameToFqn.containsKey(simpleName)) { String existingFqn = simpleNameToFqn.get(simpleName); - if (!existingFqn.equals(fqn)) { + if (existingFqn != null && !existingFqn.equals(fqn)) { ambiguousSimpleNames.add(simpleName); } } else { simpleNameToFqn.put(simpleName, fqn); } + } + + /** + * Walk {@code start} then each superclass; stop on null, missing type, {@code java.lang.Object}, or cycle. + */ + public void forEachSuperclass(TypeDeclaration start, Consumer visitor) { + if (start == null || visitor == null) { + return; + } + Set visited = new LinkedHashSet<>(); + TypeDeclaration current = start; + while (current != null) { + String fqn = getFqn(current); + if (fqn != null && !visited.add(fqn)) { + break; + } + visitor.accept(current); + String superFqn = getSuperclassFqn(current); + if (superFqn == null || "java.lang.Object".equals(superFqn)) { + break; + } + current = getTypeDeclaration(superFqn); + } + } + + private void indexRecord(org.eclipse.jdt.core.dom.RecordDeclaration rd, String parentFqn, Path javaFile) { + String simpleName = rd.getName().getIdentifier(); + String fqn = parentFqn.isEmpty() ? simpleName : parentFqn + "." + simpleName; + + if (shouldReplaceTypeIndex(fqn, javaFile)) { + // Treat as a class since it is a type + classes.put(fqn, (CompilationUnit) rd.getRoot()); + classPaths.put(fqn, javaFile); + recordDeclarations.put(fqn, rd); + registerSimpleNameForFqn(simpleName, fqn); + } // Recursively index nested types for (Object decl : rd.bodyDeclarations()) { @@ -611,24 +676,19 @@ public class CodebaseContext { private void indexEnum(EnumDeclaration ed, String parentFqn, Path javaFile) { String simpleName = ed.getName().getIdentifier(); String fqn = parentFqn.isEmpty() ? simpleName : parentFqn + "." + simpleName; - - classes.put(fqn, (CompilationUnit) ed.getRoot()); - classPaths.put(fqn, javaFile); - - List values = new ArrayList<>(); - for (Object o : ed.enumConstants()) { - if (o instanceof EnumConstantDeclaration ecd) { - values.add(fqn + "." + ecd.getName().getIdentifier()); + + if (shouldReplaceTypeIndex(fqn, javaFile)) { + classes.put(fqn, (CompilationUnit) ed.getRoot()); + classPaths.put(fqn, javaFile); + + List values = new ArrayList<>(); + for (Object o : ed.enumConstants()) { + if (o instanceof EnumConstantDeclaration ecd) { + values.add(fqn + "." + ecd.getName().getIdentifier()); + } } - } - enumValues.put(fqn, values); - if (simpleNameToFqn.containsKey(simpleName)) { - String existingFqn = simpleNameToFqn.get(simpleName); - if (existingFqn != null && !existingFqn.equals(fqn)) { - ambiguousSimpleNames.add(simpleName); - } - } else { - simpleNameToFqn.put(simpleName, fqn); + enumValues.put(fqn, values); + registerSimpleNameForFqn(simpleName, fqn); } // Recursively index nested types inside enum body @@ -1009,7 +1069,21 @@ public class CodebaseContext { } private MethodDeclaration findMethodDeclarationLegacy(TypeDeclaration td, String methodName, boolean includeSuper) { - if (td == null) return null; + return findMethodDeclarationLegacy(td, methodName, includeSuper, new HashSet<>()); + } + + private MethodDeclaration findMethodDeclarationLegacy( + TypeDeclaration td, + String methodName, + boolean includeSuper, + Set visited) { + if (td == null) { + return null; + } + String typeFqn = getFqn(td); + if (typeFqn != null && !visited.add(typeFqn)) { + return null; + } for (MethodDeclaration method : td.getMethods()) { if (method.getName().getIdentifier().equals(methodName)) { return method; @@ -1021,18 +1095,25 @@ public class CodebaseContext { if (superFqn != null) { TypeDeclaration superTd = getTypeDeclaration(superFqn); if (superTd != null) { - return findMethodDeclarationLegacy(superTd, methodName, true); + MethodDeclaration found = findMethodDeclarationLegacy(superTd, methodName, true, visited); + if (found != null) { + return found; + } } } - + // Check interfaces for (Object interfaceTypeObj : td.superInterfaceTypes()) { Type interfaceType = (Type) interfaceTypeObj; String interfaceName = extractTypeName(interfaceType); - TypeDeclaration interfaceTd = getTypeDeclaration(interfaceName, (td.getRoot() instanceof CompilationUnit) ? (CompilationUnit) td.getRoot() : null); + TypeDeclaration interfaceTd = getTypeDeclaration( + interfaceName, + (td.getRoot() instanceof CompilationUnit) ? (CompilationUnit) td.getRoot() : null); if (interfaceTd != null) { - MethodDeclaration found = findMethodDeclarationLegacy(interfaceTd, methodName, true); - if (found != null) return found; + MethodDeclaration found = findMethodDeclarationLegacy(interfaceTd, methodName, true, visited); + if (found != null) { + return found; + } } } } diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/ast/common/CodebaseContextTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/ast/common/CodebaseContextTest.java index 778b6f7..b312aad 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/ast/common/CodebaseContextTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/ast/common/CodebaseContextTest.java @@ -7,7 +7,9 @@ import org.junit.jupiter.api.io.TempDir; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; import java.util.List; +import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; @@ -327,6 +329,142 @@ class CodebaseContextTest { .containsExactlyInAnyOrder("abcd.ccc.AaaListener", "abcd.ccc.Av"); } + @Test + void getImplementationsShouldTerminateOnCircularExtends() throws IOException { + Files.createDirectories(tempDir.resolve("cycle")); + // Invalid Java inheritance cycle — still must not hang in collectImplementations / subtype walks. + Files.writeString(tempDir.resolve("cycle/A.java"), """ + package cycle; + public class A extends B {} + """); + Files.writeString(tempDir.resolve("cycle/B.java"), """ + package cycle; + public class B extends A {} + """); + + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + + assertThat(context.getImplementations("cycle.A")).contains("cycle.B"); + assertThat(context.getImplementations("cycle.B")).contains("cycle.A"); + assertThat(context.isSubtypeOrSame("cycle.A", "cycle.B")).isTrue(); + assertThat(context.isSubtypeOrSame("cycle.B", "cycle.A")).isTrue(); + assertThat(context.areClassesPolymorphicallyCompatible("cycle.A", "cycle.B")).isTrue(); + } + + @Test + void forEachSuperclassShouldVisitEachTypeOnceOnCircularExtends() throws IOException { + Files.createDirectories(tempDir.resolve("cycle")); + Files.writeString(tempDir.resolve("cycle/A.java"), """ + package cycle; + public class A extends B {} + """); + Files.writeString(tempDir.resolve("cycle/B.java"), """ + package cycle; + public class B extends A {} + """); + + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + + List visited = new ArrayList<>(); + TypeDeclaration a = context.getTypeDeclaration("cycle.A"); + context.forEachSuperclass(a, td -> visited.add(context.getFqn(td))); + + assertThat(visited).containsExactly("cycle.A", "cycle.B"); + } + + @Test + void findMethodDeclarationShouldTerminateOnCircularExtends() throws IOException { + Files.createDirectories(tempDir.resolve("cycle")); + Files.writeString(tempDir.resolve("cycle/Base.java"), """ + package cycle; + public class Base { + public void handle() {} + } + """); + Files.writeString(tempDir.resolve("cycle/A.java"), """ + package cycle; + public class A extends B {} + """); + Files.writeString(tempDir.resolve("cycle/B.java"), """ + package cycle; + public class B extends A {} + """); + + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + + TypeDeclaration a = context.getTypeDeclaration("cycle.A"); + assertThat(context.findMethodDeclaration(a, "handle", true)).isNull(); + assertThat(context.findMethodDeclaration(a, "missing", true)).isNull(); + } + + @Test + void indexShouldPreferMainSourceOverGeneratedStubForSameFqn() throws IOException { + Path module = tempDir.resolve("module"); + Path mainFoo = module.resolve("src/main/java/com/example/Foo.java"); + Path stubFoo = module.resolve("target/generated-sources/annotations/com/example/Foo.java"); + Files.createDirectories(mainFoo.getParent()); + Files.createDirectories(stubFoo.getParent()); + Files.writeString(mainFoo, """ + package com.example; + public class Foo { + public String realBody() { return "main"; } + } + """); + Files.writeString(stubFoo, """ + package com.example; + public class Foo { + public String stubBody() { return "stub"; } + } + """); + + CodebaseContext context = new CodebaseContext(); + context.scan(module); + + assertThat(context.getPath("com.example.Foo").toString()).contains("src/main/java"); + assertThat(context.getTypeDeclaration("com.example.Foo").getMethods()) + .extracting(md -> md.getName().getIdentifier()) + .contains("realBody") + .doesNotContain("stubBody"); + } + + @Test + void indexShouldPreferMainSourceOverGeneratedStubRegardlessOfScanRootOrder() throws IOException { + Path module = tempDir.resolve("module"); + Path mainFoo = module.resolve("src/main/java/com/example/Foo.java"); + Path stubFoo = module.resolve("target/generated-sources/annotations/com/example/Foo.java"); + Files.createDirectories(mainFoo.getParent()); + Files.createDirectories(stubFoo.getParent()); + Files.writeString(mainFoo, """ + package com.example; + public class Foo { public void realBody() {} } + """); + Files.writeString(stubFoo, """ + package com.example; + public class Foo { public void stubBody() {} } + """); + + CodebaseContext generatedFirst = new CodebaseContext(); + generatedFirst.scan(Set.of( + module.resolve("target/generated-sources"), + module.resolve("src/main/java")), Set.of()); + + CodebaseContext mainFirst = new CodebaseContext(); + mainFirst.scan(Set.of( + module.resolve("src/main/java"), + module.resolve("target/generated-sources")), Set.of()); + + for (CodebaseContext context : List.of(generatedFirst, mainFirst)) { + assertThat(context.getPath("com.example.Foo").toString()).contains("src/main/java"); + assertThat(context.getTypeDeclaration("com.example.Foo").getMethods()) + .extracting(md -> md.getName().getIdentifier()) + .contains("realBody") + .doesNotContain("stubBody"); + } + } + @Test void getImplementationsShouldCanonicalizeImportAwareExtendsToSameFqnBucket() throws IOException { Files.createDirectories(tempDir.resolve("abcd/ccc"));