This commit is contained in:
2026-07-17 09:16:27 +02:00
parent 7398df19ca
commit 6909af8727
5 changed files with 313 additions and 79 deletions

View File

@@ -1591,21 +1591,21 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
} }
TypeDeclaration enclosingType = findEnclosingType(receiverNameNode); TypeDeclaration enclosingType = findEnclosingType(receiverNameNode);
TypeDeclaration currentType = enclosingType; final String[] resolvedType = {null};
while (currentType != null) { context.forEachSuperclass(enclosingType, currentType -> {
if (resolvedType[0] != null) {
return;
}
for (FieldDeclaration field : currentType.getFields()) { for (FieldDeclaration field : currentType.getFields()) {
for (Object fragObj : field.fragments()) { for (Object fragObj : field.fragments()) {
VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj; VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj;
if (frag.getName().getIdentifier().equals(varName)) { 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 resolvedType[0];
}
return null;
} }
protected String resolveTypeToFqn(Type type, ASTNode contextNode) { protected String resolveTypeToFqn(Type type, ASTNode contextNode) {
@@ -1820,19 +1820,18 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
return resolved; return resolved;
} }
String superFqn = context.getSuperclassFqn(td); String superFqn = context.getSuperclassFqn(td);
while (superFqn != null) { TypeDeclaration superTd = superFqn != null ? context.getTypeDeclaration(superFqn) : null;
TypeDeclaration superTd = context.getTypeDeclaration(superFqn); if (superTd == null) {
if (superTd != null) { return null;
resolved = resolveMethodInType(superTd, methodName);
if (resolved != null) {
return resolved;
}
superFqn = context.getSuperclassFqn(superTd);
} else {
break;
}
} }
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) { protected Expression traceVariable(Expression expr) {

View File

@@ -788,11 +788,18 @@ public final class EntryPointBindingExpander {
TypeDeclaration typeDecl, TypeDeclaration typeDecl,
String fieldName, String fieldName,
CodebaseContext context) { CodebaseContext context) {
for (TypeDeclaration current = typeDecl; current != null; current = superTypeDeclaration(current, context)) { final Map<String, String>[] found = new Map[1];
context.forEachSuperclass(typeDecl, current -> {
if (found[0] != null) {
return;
}
Map<String, String> entries = extractStaticFieldMapInitializerOnType(current, fieldName, context); Map<String, String> entries = extractStaticFieldMapInitializerOnType(current, fieldName, context);
if (entries != null) { if (entries != null) {
return entries; found[0] = entries;
} }
});
if (found[0] != null) {
return found[0];
} }
return extractStaticFieldMapInitializerFromInterfaces(typeDecl, fieldName, context, new LinkedHashSet<>()); return extractStaticFieldMapInitializerFromInterfaces(typeDecl, fieldName, context, new LinkedHashSet<>());
} }
@@ -835,14 +842,6 @@ public final class EntryPointBindingExpander {
return null; 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<String, String> extractStaticFieldMapInitializerOnType( private static Map<String, String> extractStaticFieldMapInitializerOnType(
TypeDeclaration typeDecl, TypeDeclaration typeDecl,
String fieldName, String fieldName,

View File

@@ -280,13 +280,30 @@ public class TypeResolver {
if (td != null) { if (td != null) {
if (td instanceof TypeDeclaration typeDecl) { if (td instanceof TypeDeclaration typeDecl) {
String superFqn = context.getSuperclassFqn(typeDecl); String superFqn = context.getSuperclassFqn(typeDecl);
while (superFqn != null) { TypeDeclaration superTd = superFqn != null ? context.getTypeDeclaration(superFqn) : null;
if (superFqn.contains("<")) { if (superTd != null) {
superFqn = superFqn.substring(0, superFqn.indexOf('<')); 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(); List interfaces = java.util.Collections.emptyList();

View File

@@ -35,6 +35,7 @@ import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.function.Consumer;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@Slf4j @Slf4j
@@ -378,18 +379,12 @@ public class CodebaseContext {
private void indexType(TypeDeclaration td, String parentFqn, Path javaFile) { private void indexType(TypeDeclaration td, String parentFqn, Path javaFile) {
String simpleName = td.getName().getIdentifier(); String simpleName = td.getName().getIdentifier();
String fqn = parentFqn.isEmpty() ? simpleName : parentFqn + "." + simpleName; 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)) { if (shouldReplaceTypeIndex(fqn, javaFile)) {
String existingFqn = simpleNameToFqn.get(simpleName); classes.put(fqn, (CompilationUnit) td.getRoot());
if (!existingFqn.equals(fqn)) { classPaths.put(fqn, javaFile);
ambiguousSimpleNames.add(simpleName); typeDeclarations.put(fqn, td);
} registerSimpleNameForFqn(simpleName, fqn);
} else {
simpleNameToFqn.put(simpleName, fqn);
} }
// Recursively index nested types // Recursively index nested types
@@ -452,23 +447,93 @@ public class CodebaseContext {
return raw; return raw;
} }
private void indexRecord(org.eclipse.jdt.core.dom.RecordDeclaration rd, String parentFqn, Path javaFile) { /**
String simpleName = rd.getName().getIdentifier(); * Higher rank wins on duplicate FQN indexing: main sources beat build artifacts.
String fqn = parentFqn.isEmpty() ? simpleName : parentFqn + "." + simpleName; */
static int sourcePathRank(Path path) {
// Treat as a class since it is a type if (path == null) {
classes.put(fqn, (CompilationUnit) rd.getRoot()); return 0;
classPaths.put(fqn, javaFile); }
recordDeclarations.put(fqn, rd); 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)) { if (simpleNameToFqn.containsKey(simpleName)) {
String existingFqn = simpleNameToFqn.get(simpleName); String existingFqn = simpleNameToFqn.get(simpleName);
if (!existingFqn.equals(fqn)) { if (existingFqn != null && !existingFqn.equals(fqn)) {
ambiguousSimpleNames.add(simpleName); ambiguousSimpleNames.add(simpleName);
} }
} else { } else {
simpleNameToFqn.put(simpleName, fqn); 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<TypeDeclaration> visitor) {
if (start == null || visitor == null) {
return;
}
Set<String> 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 // Recursively index nested types
for (Object decl : rd.bodyDeclarations()) { for (Object decl : rd.bodyDeclarations()) {
@@ -611,24 +676,19 @@ public class CodebaseContext {
private void indexEnum(EnumDeclaration ed, String parentFqn, Path javaFile) { private void indexEnum(EnumDeclaration ed, String parentFqn, Path javaFile) {
String simpleName = ed.getName().getIdentifier(); String simpleName = ed.getName().getIdentifier();
String fqn = parentFqn.isEmpty() ? simpleName : parentFqn + "." + simpleName; String fqn = parentFqn.isEmpty() ? simpleName : parentFqn + "." + simpleName;
classes.put(fqn, (CompilationUnit) ed.getRoot()); if (shouldReplaceTypeIndex(fqn, javaFile)) {
classPaths.put(fqn, javaFile); classes.put(fqn, (CompilationUnit) ed.getRoot());
classPaths.put(fqn, javaFile);
List<String> values = new ArrayList<>();
for (Object o : ed.enumConstants()) { List<String> values = new ArrayList<>();
if (o instanceof EnumConstantDeclaration ecd) { for (Object o : ed.enumConstants()) {
values.add(fqn + "." + ecd.getName().getIdentifier()); if (o instanceof EnumConstantDeclaration ecd) {
values.add(fqn + "." + ecd.getName().getIdentifier());
}
} }
} enumValues.put(fqn, values);
enumValues.put(fqn, values); registerSimpleNameForFqn(simpleName, fqn);
if (simpleNameToFqn.containsKey(simpleName)) {
String existingFqn = simpleNameToFqn.get(simpleName);
if (existingFqn != null && !existingFqn.equals(fqn)) {
ambiguousSimpleNames.add(simpleName);
}
} else {
simpleNameToFqn.put(simpleName, fqn);
} }
// Recursively index nested types inside enum body // Recursively index nested types inside enum body
@@ -1009,7 +1069,21 @@ public class CodebaseContext {
} }
private MethodDeclaration findMethodDeclarationLegacy(TypeDeclaration td, String methodName, boolean includeSuper) { 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<String> visited) {
if (td == null) {
return null;
}
String typeFqn = getFqn(td);
if (typeFqn != null && !visited.add(typeFqn)) {
return null;
}
for (MethodDeclaration method : td.getMethods()) { for (MethodDeclaration method : td.getMethods()) {
if (method.getName().getIdentifier().equals(methodName)) { if (method.getName().getIdentifier().equals(methodName)) {
return method; return method;
@@ -1021,18 +1095,25 @@ public class CodebaseContext {
if (superFqn != null) { if (superFqn != null) {
TypeDeclaration superTd = getTypeDeclaration(superFqn); TypeDeclaration superTd = getTypeDeclaration(superFqn);
if (superTd != null) { if (superTd != null) {
return findMethodDeclarationLegacy(superTd, methodName, true); MethodDeclaration found = findMethodDeclarationLegacy(superTd, methodName, true, visited);
if (found != null) {
return found;
}
} }
} }
// Check interfaces // Check interfaces
for (Object interfaceTypeObj : td.superInterfaceTypes()) { for (Object interfaceTypeObj : td.superInterfaceTypes()) {
Type interfaceType = (Type) interfaceTypeObj; Type interfaceType = (Type) interfaceTypeObj;
String interfaceName = extractTypeName(interfaceType); 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) { if (interfaceTd != null) {
MethodDeclaration found = findMethodDeclarationLegacy(interfaceTd, methodName, true); MethodDeclaration found = findMethodDeclarationLegacy(interfaceTd, methodName, true, visited);
if (found != null) return found; if (found != null) {
return found;
}
} }
} }
} }

View File

@@ -7,7 +7,9 @@ import org.junit.jupiter.api.io.TempDir;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
@@ -327,6 +329,142 @@ class CodebaseContextTest {
.containsExactlyInAnyOrder("abcd.ccc.AaaListener", "abcd.ccc.Av"); .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<String> 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 @Test
void getImplementationsShouldCanonicalizeImportAwareExtendsToSameFqnBucket() throws IOException { void getImplementationsShouldCanonicalizeImportAwareExtendsToSameFqnBucket() throws IOException {
Files.createDirectories(tempDir.resolve("abcd/ccc")); Files.createDirectories(tempDir.resolve("abcd/ccc"));