This commit is contained in:
2026-07-17 04:49:09 +02:00
parent 5300fdb881
commit d4c0de1d0a
6 changed files with 419 additions and 60 deletions

View File

@@ -112,11 +112,12 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
for (String sMethod : startMethods) { for (String sMethod : startMethods) {
if (initialBindings.isEmpty()) { if (initialBindings.isEmpty()) {
allPaths.addAll(pathFinder.findAllPaths( allPaths.addAll(pathFinder.findAllPaths(
sMethod, targetMethod, callGraph, new HashSet<>())); sMethod, targetMethod, callGraph, new HashSet<>(),
ep.getClassName()));
} else { } else {
allPaths.addAll(pathFinder.findAllPaths( allPaths.addAll(pathFinder.findAllPaths(
sMethod, targetMethod, callGraph, new HashSet<>(), sMethod, targetMethod, callGraph, new HashSet<>(),
pathBindingEvaluator, initialBindings)); pathBindingEvaluator, initialBindings, ep.getClassName()));
} }
} }
Set<List<String>> uniquePaths = new LinkedHashSet<>(allPaths); Set<List<String>> uniquePaths = new LinkedHashSet<>(allPaths);

View File

@@ -2,17 +2,25 @@ package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.CallEdge; import click.kamil.springstatemachineexporter.analysis.model.CallEdge;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import java.util.*; import org.eclipse.jdt.core.dom.Modifier;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
@Slf4j @Slf4j
public class CallGraphPathFinder { public class CallGraphPathFinder {
private final CodebaseContext context; private final CodebaseContext context;
private final Map<java.util.Map.Entry<String, String>, Boolean> heuristicCache = new HashMap<>(); private final Map<Map.Entry<String, String>, Boolean> heuristicCache = new HashMap<>();
private final Map<java.util.Map.Entry<String, String>, List<List<String>>> memoCache = new HashMap<>(); /** Memo key: start|target|concreteSelf */
private final Map<java.util.Map.Entry<String, String>, Boolean> unreachableCache = new HashMap<>(); private final Map<String, List<List<String>>> memoCache = new HashMap<>();
private final Map<String, Boolean> unreachableCache = new HashMap<>();
public CallGraphPathFinder(CodebaseContext context) { public CallGraphPathFinder(CodebaseContext context) {
this.context = context; this.context = context;
@@ -49,17 +57,26 @@ public class CallGraphPathFinder {
} }
} }
} }
if (log.isDebugEnabled()) { if (log.isDebugEnabled()) {
log.debug("Path search dead-end at {} when looking for {}", start, target); log.debug("Path search dead-end at {} when looking for {}", start, target);
} }
visited.remove(start); visited.remove(start);
return null; return null;
} }
public List<List<String>> findAllPaths(String start, String target, Map<String, List<CallEdge>> graph, Set<String> visited) { public List<List<String>> findAllPaths(String start, String target, Map<String, List<CallEdge>> graph, Set<String> visited) {
return findAllPaths(start, target, graph, visited, new boolean[]{false}); return findAllPaths(start, target, graph, visited, new boolean[]{false}, null);
}
public List<List<String>> findAllPaths(
String start,
String target,
Map<String, List<CallEdge>> graph,
Set<String> visited,
String concreteSelfFqn) {
return findAllPaths(start, target, graph, visited, new boolean[]{false}, concreteSelfFqn);
} }
public List<List<String>> findAllPaths( public List<List<String>> findAllPaths(
@@ -69,17 +86,43 @@ public class CallGraphPathFinder {
Set<String> visited, Set<String> visited,
PathBindingEvaluator bindingEvaluator, PathBindingEvaluator bindingEvaluator,
Map<String, String> initialBindings) { Map<String, String> initialBindings) {
return findAllPaths(start, target, graph, visited, bindingEvaluator, initialBindings, null);
}
public List<List<String>> findAllPaths(
String start,
String target,
Map<String, List<CallEdge>> graph,
Set<String> visited,
PathBindingEvaluator bindingEvaluator,
Map<String, String> initialBindings,
String concreteSelfFqn) {
if (bindingEvaluator == null) { if (bindingEvaluator == null) {
return findAllPaths(start, target, graph, visited); return findAllPaths(start, target, graph, visited, concreteSelfFqn);
} }
Map<String, String> bindings = initialBindings == null ? new HashMap<>() : new HashMap<>(initialBindings); Map<String, String> bindings = initialBindings == null ? new HashMap<>() : new HashMap<>(initialBindings);
return findAllPathsConstrained( return findAllPathsConstrained(
start, target, graph, visited, new boolean[]{false}, bindingEvaluator, bindings); start, target, graph, visited, new boolean[]{false}, bindingEvaluator, bindings, concreteSelfFqn);
} }
public List<List<String>> findAllPaths(String start, String target, Map<String, List<CallEdge>> graph, Set<String> visited, boolean[] parentHitCycle) { public List<List<String>> findAllPaths(
String start,
String target,
Map<String, List<CallEdge>> graph,
Set<String> visited,
boolean[] parentHitCycle) {
return findAllPaths(start, target, graph, visited, parentHitCycle, null);
}
public List<List<String>> findAllPaths(
String start,
String target,
Map<String, List<CallEdge>> graph,
Set<String> visited,
boolean[] parentHitCycle,
String concreteSelfFqn) {
List<List<String>> result = new ArrayList<>(); List<List<String>> result = new ArrayList<>();
java.util.Map.Entry<String, String> cacheKey = new java.util.AbstractMap.SimpleImmutableEntry<>(start, target); String cacheKey = memoKey(start, target, concreteSelfFqn);
if (unreachableCache.containsKey(cacheKey)) { if (unreachableCache.containsKey(cacheKey)) {
return result; return result;
} }
@@ -110,17 +153,26 @@ public class CallGraphPathFinder {
} }
boolean[] localHitCycle = new boolean[]{false}; boolean[] localHitCycle = new boolean[]{false};
String selfAtStart = concreteSelfFqn;
List<CallEdge> neighbors = graph.get(start); List<CallEdge> neighbors = graph.get(start);
if (neighbors != null) { if (neighbors != null) {
for (CallEdge edge : neighbors) { for (CallEdge edge : neighbors) {
String neighbor = edge.getTargetMethod(); String neighbor = edge.getTargetMethod();
if (neighbor.startsWith("java.") || neighbor.startsWith("javax.") || neighbor.startsWith("jakarta.") || neighbor.startsWith("org.springframework.")) continue; if (neighbor.startsWith("java.") || neighbor.startsWith("javax.") || neighbor.startsWith("jakarta.")
|| neighbor.startsWith("org.springframework.")) {
continue;
}
if (!acceptVirtualHop(start, neighbor, selfAtStart)) {
continue;
}
String nextSelf = nextConcreteSelf(selfAtStart, start, neighbor);
if (neighbor.equals(target) || isHeuristicMatch(neighbor, target)) { if (neighbor.equals(target) || isHeuristicMatch(neighbor, target)) {
List<String> path = new ArrayList<>(List.of(start, target)); List<String> path = new ArrayList<>(List.of(start, target));
result.add(path); result.add(path);
} else { } else {
List<List<String>> subPaths = findAllPaths(neighbor, target, graph, visited, localHitCycle); List<List<String>> subPaths =
findAllPaths(neighbor, target, graph, visited, localHitCycle, nextSelf);
for (List<String> subPath : subPaths) { for (List<String> subPath : subPaths) {
List<String> path = new ArrayList<>(); List<String> path = new ArrayList<>();
path.add(start); path.add(start);
@@ -130,21 +182,19 @@ public class CallGraphPathFinder {
} }
} }
} }
// Handle inheritance fallback for concrete class methods not in the graph // Handle inheritance fallback for concrete class methods not in the graph
if (result.isEmpty() && start.contains(".")) { if (result.isEmpty() && start.contains(".")) {
String className = start.substring(0, start.lastIndexOf('.')); String className = classNameOf(start);
String methodName = start.substring(start.lastIndexOf('.') + 1); String methodName = methodNameOf(start);
if (className.contains("<")) {
className = className.substring(0, className.indexOf('<'));
}
TypeDeclaration td = context.getTypeDeclaration(className); TypeDeclaration td = context.getTypeDeclaration(className);
if (td != null) { if (td != null) {
String superclass = context.getSuperclassFqn(td); String superclass = context.getSuperclassFqn(td);
if (superclass != null) { if (superclass != null) {
String superMethod = superclass + "." + methodName; String superMethod = superclass + "." + methodName;
if (graph.containsKey(superMethod)) { if (graph.containsKey(superMethod)) {
List<List<String>> superPaths = findAllPaths(superMethod, target, graph, visited, localHitCycle); List<List<String>> superPaths =
findAllPaths(superMethod, target, graph, visited, localHitCycle, selfAtStart);
for (List<String> superPath : superPaths) { for (List<String> superPath : superPaths) {
List<String> path = new ArrayList<>(); List<String> path = new ArrayList<>();
path.add(start); path.add(start);
@@ -158,17 +208,19 @@ public class CallGraphPathFinder {
// Handle implementation fallback for interfaces/superclasses (interface -> concrete class) // Handle implementation fallback for interfaces/superclasses (interface -> concrete class)
if (result.isEmpty() && start.contains(".")) { if (result.isEmpty() && start.contains(".")) {
String className = start.substring(0, start.lastIndexOf('.')); String className = classNameOf(start);
String methodName = start.substring(start.lastIndexOf('.') + 1); String methodName = methodNameOf(start);
if (className.contains("<")) {
className = className.substring(0, className.indexOf('<'));
}
List<String> impls = context.getImplementations(className); List<String> impls = context.getImplementations(className);
if (impls != null) { if (impls != null) {
for (String impl : impls) { for (String impl : impls) {
if (!acceptImplementationFallback(impl, selfAtStart)) {
continue;
}
String implMethod = impl + "." + methodName; String implMethod = impl + "." + methodName;
if (graph.containsKey(implMethod)) { if (graph.containsKey(implMethod)) {
List<List<String>> implPaths = findAllPaths(implMethod, target, graph, visited, localHitCycle); String nextSelf = nextConcreteSelfForImpl(selfAtStart, impl);
List<List<String>> implPaths =
findAllPaths(implMethod, target, graph, visited, localHitCycle, nextSelf);
for (List<String> implPath : implPaths) { for (List<String> implPath : implPaths) {
List<String> path = new ArrayList<>(); List<String> path = new ArrayList<>();
path.add(start); path.add(start);
@@ -180,7 +232,7 @@ public class CallGraphPathFinder {
} }
} }
visited.remove(start); visited.remove(start);
if (localHitCycle[0]) { if (localHitCycle[0]) {
parentHitCycle[0] = true; parentHitCycle[0] = true;
} else if (result.isEmpty()) { } else if (result.isEmpty()) {
@@ -192,7 +244,7 @@ public class CallGraphPathFinder {
} }
memoCache.put(cacheKey, toCache); memoCache.put(cacheKey, toCache);
} }
return result; return result;
} }
@@ -203,7 +255,8 @@ public class CallGraphPathFinder {
Set<String> visited, Set<String> visited,
boolean[] parentHitCycle, boolean[] parentHitCycle,
PathBindingEvaluator bindingEvaluator, PathBindingEvaluator bindingEvaluator,
Map<String, String> bindings) { Map<String, String> bindings,
String concreteSelfFqn) {
List<List<String>> result = new ArrayList<>(); List<List<String>> result = new ArrayList<>();
if (start.equals(target)) { if (start.equals(target)) {
result.add(new ArrayList<>(List.of(start))); result.add(new ArrayList<>(List.of(start)));
@@ -214,11 +267,16 @@ public class CallGraphPathFinder {
return result; return result;
} }
String selfAtStart = concreteSelfFqn;
List<CallEdge> neighbors = graph.get(start); List<CallEdge> neighbors = graph.get(start);
if (neighbors != null) { if (neighbors != null) {
for (CallEdge edge : neighbors) { for (CallEdge edge : neighbors) {
String neighbor = edge.getTargetMethod(); String neighbor = edge.getTargetMethod();
if (neighbor.startsWith("java.") || neighbor.startsWith("javax.") || neighbor.startsWith("jakarta.") || neighbor.startsWith("org.springframework.")) { if (neighbor.startsWith("java.") || neighbor.startsWith("javax.") || neighbor.startsWith("jakarta.")
|| neighbor.startsWith("org.springframework.")) {
continue;
}
if (!acceptVirtualHop(start, neighbor, selfAtStart)) {
continue; continue;
} }
@@ -230,11 +288,13 @@ public class CallGraphPathFinder {
continue; continue;
} }
String nextSelf = nextConcreteSelf(selfAtStart, start, neighbor);
if (resolvedTarget.equals(target)) { if (resolvedTarget.equals(target)) {
result.add(hopPath); result.add(hopPath);
} else { } else {
List<List<String>> subPaths = findAllPathsConstrained( List<List<String>> subPaths = findAllPathsConstrained(
neighbor, target, graph, visited, parentHitCycle, bindingEvaluator, branchBindings); neighbor, target, graph, visited, parentHitCycle, bindingEvaluator, branchBindings,
nextSelf);
for (List<String> subPath : subPaths) { for (List<String> subPath : subPaths) {
if (subPath.isEmpty() || subPath.get(0).equals(start)) { if (subPath.isEmpty() || subPath.get(0).equals(start)) {
continue; continue;
@@ -250,7 +310,7 @@ public class CallGraphPathFinder {
if (result.isEmpty() && start.contains(".")) { if (result.isEmpty() && start.contains(".")) {
result.addAll(findInheritanceFallbackPaths( result.addAll(findInheritanceFallbackPaths(
start, target, graph, visited, parentHitCycle, bindingEvaluator, bindings)); start, target, graph, visited, parentHitCycle, bindingEvaluator, bindings, selfAtStart));
} }
visited.remove(start); visited.remove(start);
@@ -264,13 +324,11 @@ public class CallGraphPathFinder {
Set<String> visited, Set<String> visited,
boolean[] parentHitCycle, boolean[] parentHitCycle,
PathBindingEvaluator bindingEvaluator, PathBindingEvaluator bindingEvaluator,
Map<String, String> bindings) { Map<String, String> bindings,
String concreteSelfFqn) {
List<List<String>> result = new ArrayList<>(); List<List<String>> result = new ArrayList<>();
String className = start.substring(0, start.lastIndexOf('.')); String className = classNameOf(start);
String methodName = start.substring(start.lastIndexOf('.') + 1); String methodName = methodNameOf(start);
if (className.contains("<")) {
className = className.substring(0, className.indexOf('<'));
}
TypeDeclaration td = context.getTypeDeclaration(className); TypeDeclaration td = context.getTypeDeclaration(className);
if (td != null) { if (td != null) {
@@ -279,7 +337,8 @@ public class CallGraphPathFinder {
String superMethod = superclass + "." + methodName; String superMethod = superclass + "." + methodName;
if (graph.containsKey(superMethod)) { if (graph.containsKey(superMethod)) {
List<List<String>> superPaths = findAllPathsConstrained( List<List<String>> superPaths = findAllPathsConstrained(
superMethod, target, graph, visited, parentHitCycle, bindingEvaluator, bindings); superMethod, target, graph, visited, parentHitCycle, bindingEvaluator, bindings,
concreteSelfFqn);
for (List<String> superPath : superPaths) { for (List<String> superPath : superPaths) {
List<String> path = new ArrayList<>(); List<String> path = new ArrayList<>();
path.add(start); path.add(start);
@@ -294,10 +353,15 @@ public class CallGraphPathFinder {
List<String> impls = context.getImplementations(className); List<String> impls = context.getImplementations(className);
if (impls != null) { if (impls != null) {
for (String impl : impls) { for (String impl : impls) {
if (!acceptImplementationFallback(impl, concreteSelfFqn)) {
continue;
}
String implMethod = impl + "." + methodName; String implMethod = impl + "." + methodName;
if (graph.containsKey(implMethod)) { if (graph.containsKey(implMethod)) {
String nextSelf = nextConcreteSelfForImpl(concreteSelfFqn, impl);
List<List<String>> implPaths = findAllPathsConstrained( List<List<String>> implPaths = findAllPathsConstrained(
implMethod, target, graph, visited, parentHitCycle, bindingEvaluator, bindings); implMethod, target, graph, visited, parentHitCycle, bindingEvaluator, bindings,
nextSelf);
for (List<String> implPath : implPaths) { for (List<String> implPath : implPaths) {
List<String> path = new ArrayList<>(); List<String> path = new ArrayList<>();
path.add(start); path.add(start);
@@ -311,6 +375,99 @@ public class CallGraphPathFinder {
return result; return result;
} }
/**
* Reject CHA fan-out edges that dispatch to a sibling subclass incompatible with the entry's
* concrete runtime type. Fail open when concreteSelf is absent or not itself concrete.
*/
private boolean acceptVirtualHop(String callerMethod, String targetMethod, String concreteSelfFqn) {
if (!isPruningActive(concreteSelfFqn)) {
return true;
}
if (!isVirtualDispatchCandidate(callerMethod, targetMethod)) {
return true;
}
String targetClass = classNameOf(targetMethod);
return targetClass != null && context.isSubtypeOrSame(concreteSelfFqn, targetClass);
}
private boolean acceptImplementationFallback(String implClassFqn, String concreteSelfFqn) {
if (!isPruningActive(concreteSelfFqn)) {
return true;
}
return context.isSubtypeOrSame(concreteSelfFqn, implClassFqn);
}
private boolean isPruningActive(String concreteSelfFqn) {
return concreteSelfFqn != null
&& !concreteSelfFqn.isBlank()
&& context.isConcreteClassType(concreteSelfFqn);
}
/**
* An edge from an abstract/interface declaring type into one of its subtypes/implementations
* is a virtual-dispatch fan-out (template-method hook expansion).
*/
private boolean isVirtualDispatchCandidate(String callerMethod, String targetMethod) {
String callerClass = classNameOf(callerMethod);
String targetClass = classNameOf(targetMethod);
if (callerClass == null || targetClass == null || callerClass.equals(targetClass)) {
return false;
}
TypeDeclaration callerTd = context.getTypeDeclaration(callerClass);
if (callerTd == null) {
return false;
}
boolean callerPolymorphic = callerTd.isInterface() || Modifier.isAbstract(callerTd.getModifiers());
if (!callerPolymorphic) {
return false;
}
if (context.isSubtypeOrSame(targetClass, callerClass)) {
return true;
}
List<String> impls = context.getImplementations(callerClass);
return impls != null && impls.contains(targetClass);
}
private String nextConcreteSelf(String concreteSelfFqn, String callerMethod, String targetMethod) {
if (!isVirtualDispatchCandidate(callerMethod, targetMethod)) {
return concreteSelfFqn;
}
String targetClass = classNameOf(targetMethod);
if (targetClass != null && context.isConcreteClassType(targetClass)) {
return targetClass;
}
return concreteSelfFqn;
}
private String nextConcreteSelfForImpl(String concreteSelfFqn, String implClassFqn) {
if (implClassFqn != null && context.isConcreteClassType(implClassFqn)) {
return implClassFqn;
}
return concreteSelfFqn;
}
private static String memoKey(String start, String target, String concreteSelfFqn) {
return start + "|" + target + "|" + Objects.toString(concreteSelfFqn, "");
}
private static String classNameOf(String methodFqn) {
if (methodFqn == null || !methodFqn.contains(".")) {
return null;
}
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
if (className.contains("<")) {
className = className.substring(0, className.indexOf('<'));
}
return className;
}
private static String methodNameOf(String methodFqn) {
if (methodFqn == null || !methodFqn.contains(".")) {
return methodFqn;
}
return methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
}
public String extractContextMachineId(List<String> path, Map<String, List<CallEdge>> callGraph) { public String extractContextMachineId(List<String> path, Map<String, List<CallEdge>> callGraph) {
for (String node : path) { for (String node : path) {
List<CallEdge> edges = callGraph.get(node); List<CallEdge> edges = callGraph.get(node);
@@ -332,7 +489,7 @@ public class CallGraphPathFinder {
if (neighbor == null || target == null) return false; if (neighbor == null || target == null) return false;
if (neighbor.equals(target)) return true; if (neighbor.equals(target)) return true;
java.util.Map.Entry<String, String> cacheKey = new java.util.AbstractMap.SimpleImmutableEntry<>(neighbor, target); Map.Entry<String, String> cacheKey = new java.util.AbstractMap.SimpleImmutableEntry<>(neighbor, target);
Boolean cached = heuristicCache.get(cacheKey); Boolean cached = heuristicCache.get(cacheKey);
if (cached != null) return cached; if (cached != null) return cached;
@@ -348,21 +505,21 @@ public class CallGraphPathFinder {
int lastDotNeighbor = neighbor.lastIndexOf('.'); int lastDotNeighbor = neighbor.lastIndexOf('.');
int lastDotTarget = target.lastIndexOf('.'); int lastDotTarget = target.lastIndexOf('.');
if (lastDotNeighbor < 0 || lastDotTarget < 0) return false; if (lastDotNeighbor < 0 || lastDotTarget < 0) return false;
String methodNeighbor = neighbor.substring(lastDotNeighbor + 1); String methodNeighbor = neighbor.substring(lastDotNeighbor + 1);
String methodTarget = target.substring(lastDotTarget + 1); String methodTarget = target.substring(lastDotTarget + 1);
if (!methodNeighbor.equals(methodTarget)) { if (!methodNeighbor.equals(methodTarget)) {
return false; return false;
} }
String classNeighbor = neighbor.substring(0, lastDotNeighbor); String classNeighbor = neighbor.substring(0, lastDotNeighbor);
String classTarget = target.substring(0, lastDotTarget); String classTarget = target.substring(0, lastDotTarget);
if (classNeighbor.equals(classTarget)) return true; if (classNeighbor.equals(classTarget)) return true;
if (context.areSameTypeOrUnambiguousSimpleMatch(classNeighbor, classTarget)) return true; if (context.areSameTypeOrUnambiguousSimpleMatch(classNeighbor, classTarget)) return true;
if (context.areClassesPolymorphicallyCompatible(classNeighbor, classTarget)) { if (context.areClassesPolymorphicallyCompatible(classNeighbor, classTarget)) {
return true; return true;
} }
@@ -373,11 +530,13 @@ public class CallGraphPathFinder {
String simpleTarget = target.contains(".") ? target.substring(target.lastIndexOf('.') + 1) : target; String simpleTarget = target.contains(".") ? target.substring(target.lastIndexOf('.') + 1) : target;
String simpleNeighbor = neighbor.contains(".") ? neighbor.substring(neighbor.lastIndexOf('.') + 1) : neighbor; String simpleNeighbor = neighbor.contains(".") ? neighbor.substring(neighbor.lastIndexOf('.') + 1) : neighbor;
if (!simpleNeighbor.equals(simpleTarget)) return false; if (!simpleNeighbor.equals(simpleTarget)) return false;
String classNeighbor = neighbor.contains(".") ? neighbor.substring(0, neighbor.lastIndexOf('.')) : null; String classNeighbor = neighbor.contains(".") ? neighbor.substring(0, neighbor.lastIndexOf('.')) : null;
String classTarget = target.contains(".") ? target.substring(0, target.lastIndexOf('.')) : null; String classTarget = target.contains(".") ? target.substring(0, target.lastIndexOf('.')) : null;
String simpleClassTarget = classTarget != null && classTarget.contains(".") ? classTarget.substring(classTarget.lastIndexOf('.') + 1) : classTarget; String simpleClassTarget = classTarget != null && classTarget.contains(".")
String simpleClassNeighbor = classNeighbor != null && classNeighbor.contains(".") ? classNeighbor.substring(classNeighbor.lastIndexOf('.') + 1) : classNeighbor; ? classTarget.substring(classTarget.lastIndexOf('.') + 1) : classTarget;
String simpleClassNeighbor = classNeighbor != null && classNeighbor.contains(".")
? classNeighbor.substring(classNeighbor.lastIndexOf('.') + 1) : classNeighbor;
if (simpleClassNeighbor != null && simpleClassTarget != null) { if (simpleClassNeighbor != null && simpleClassTarget != null) {
String leftClass = classNeighbor != null ? classNeighbor : simpleClassNeighbor; String leftClass = classNeighbor != null ? classNeighbor : simpleClassNeighbor;

View File

@@ -69,6 +69,7 @@ public class CodebaseContext {
private final Map<String, MethodDeclaration> methodDeclarationCache = new HashMap<>(); private final Map<String, MethodDeclaration> methodDeclarationCache = new HashMap<>();
private final Map<String, String> superclassFqnCache = new HashMap<>(); private final Map<String, String> superclassFqnCache = new HashMap<>();
private final Map<String, List<String>> implementationsCache = new HashMap<>(); private final Map<String, List<String>> implementationsCache = new HashMap<>();
private final Map<String, Boolean> subtypeOrSameCache = new HashMap<>();
private boolean inlineAccessors = true; private boolean inlineAccessors = true;
public Map<String, Object> getCache() { public Map<String, Object> getCache() {
@@ -107,6 +108,7 @@ public class CodebaseContext {
methodDeclarationCache.clear(); methodDeclarationCache.clear();
superclassFqnCache.clear(); superclassFqnCache.clear();
classCompatibilityCache.clear(); classCompatibilityCache.clear();
subtypeOrSameCache.clear();
} }
public Set<String> getIndexedTypeFqns() { public Set<String> getIndexedTypeFqns() {
@@ -739,6 +741,72 @@ public class CodebaseContext {
return resolved; return resolved;
} }
/**
* AST-backed subtype check: {@code true} when {@code childFqn} is the same type as
* {@code parentFqn}, or extends/implements it (transitively). Does not require JDT bindings.
*/
public boolean isSubtypeOrSame(String childFqn, String parentFqn) {
if (childFqn == null || parentFqn == null || childFqn.isBlank() || parentFqn.isBlank()) {
return false;
}
if (childFqn.equals(parentFqn) || areSameTypeOrUnambiguousSimpleMatch(childFqn, parentFqn)) {
return true;
}
String cacheKey = childFqn + "<:" + parentFqn;
Boolean cached = subtypeOrSameCache.get(cacheKey);
if (cached != null) {
return cached;
}
boolean result = computeIsSubtypeOrSame(childFqn, parentFqn, new HashSet<>());
subtypeOrSameCache.put(cacheKey, result);
return result;
}
/**
* {@code true} when the type is a known non-abstract class (not an interface).
* Unknown types are treated as non-concrete so pathfinding fails open.
*/
public boolean isConcreteClassType(String typeFqn) {
if (typeFqn == null || typeFqn.isBlank()) {
return false;
}
TypeDeclaration td = getTypeDeclaration(typeFqn);
if (td == null) {
return false;
}
return !td.isInterface() && !Modifier.isAbstract(td.getModifiers());
}
private boolean computeIsSubtypeOrSame(String childFqn, String parentFqn, Set<String> visited) {
if (childFqn == null || !visited.add(childFqn)) {
return false;
}
if (childFqn.equals(parentFqn) || areSameTypeOrUnambiguousSimpleMatch(childFqn, parentFqn)) {
return true;
}
TypeDeclaration childTd = getTypeDeclaration(childFqn);
if (childTd == null) {
return false;
}
String superclass = getSuperclassFqn(childTd);
if (superclass != null && computeIsSubtypeOrSame(superclass, parentFqn, visited)) {
return true;
}
CompilationUnit cu = childTd.getRoot() instanceof CompilationUnit root ? root : null;
for (Object interfaceTypeObj : childTd.superInterfaceTypes()) {
if (!(interfaceTypeObj instanceof Type interfaceType)) {
continue;
}
String interfaceName = extractTypeName(interfaceType);
TypeDeclaration interfaceTd = getTypeDeclaration(interfaceName, cu);
String interfaceFqn = interfaceTd != null ? getFqn(interfaceTd) : interfaceName;
if (interfaceFqn != null && computeIsSubtypeOrSame(interfaceFqn, parentFqn, visited)) {
return true;
}
}
return false;
}
private String resolveSuperclassFqn(TypeDeclaration td) { private String resolveSuperclassFqn(TypeDeclaration td) {
String bindingSuper = null; String bindingSuper = null;
ITypeBinding binding = td.resolveBinding(); ITypeBinding binding = td.resolveBinding();

View File

@@ -5,6 +5,8 @@ import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*; import java.util.*;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
@@ -114,4 +116,106 @@ class CallGraphPathFinderTest {
"com.acme.BranchA.step", "com.acme.BranchA.step",
"com.acme.End.finish"); "com.acme.End.finish");
} }
@Test
void shouldRejectSiblingSubclassVirtualHopsWhenConcreteSelfIsKnown() throws Exception {
Path tempDir = Files.createTempDirectory("pathfinder_concrete_self");
Files.writeString(tempDir.resolve("Listeners.java"), """
package com.example;
abstract class AbstractListener {
void processMessage() { doPMessage(); }
abstract void doPMessage();
}
class OrderListener extends AbstractListener {
void onMessage() { processMessage(); }
void doPMessage() {}
}
class DocumentListener extends AbstractListener {
void onMessage() { processMessage(); }
void doPMessage() {}
}
class StateMachine {
void sendEvent() {}
}
""");
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
CallGraphPathFinder pathFinder = new CallGraphPathFinder(context);
Map<String, List<CallEdge>> graph = new HashMap<>();
graph.put("com.example.OrderListener.onMessage", List.of(
new CallEdge("com.example.AbstractListener.processMessage", List.of())));
graph.put("com.example.AbstractListener.processMessage", List.of(
new CallEdge("com.example.OrderListener.doPMessage", List.of()),
new CallEdge("com.example.DocumentListener.doPMessage", List.of())));
graph.put("com.example.OrderListener.doPMessage", List.of(
new CallEdge("com.example.StateMachine.sendEvent", List.of())));
graph.put("com.example.DocumentListener.doPMessage", List.of(
new CallEdge("com.example.StateMachine.sendEvent", List.of())));
List<List<String>> withoutSelf = pathFinder.findAllPaths(
"com.example.OrderListener.onMessage",
"com.example.StateMachine.sendEvent",
graph,
new HashSet<>());
assertThat(withoutSelf).hasSize(2);
List<List<String>> withOrderSelf = pathFinder.findAllPaths(
"com.example.OrderListener.onMessage",
"com.example.StateMachine.sendEvent",
graph,
new HashSet<>(),
"com.example.OrderListener");
assertThat(withOrderSelf).hasSize(1);
assertThat(withOrderSelf.get(0))
.containsExactly(
"com.example.OrderListener.onMessage",
"com.example.AbstractListener.processMessage",
"com.example.OrderListener.doPMessage",
"com.example.StateMachine.sendEvent")
.doesNotContain("com.example.DocumentListener.doPMessage");
}
@Test
void shouldAllowParentOverrideWhenConcreteSelfIsMoreSpecificSubclass() throws Exception {
Path tempDir = Files.createTempDirectory("pathfinder_parent_override");
Files.writeString(tempDir.resolve("Hierarchy.java"), """
package com.example;
abstract class AbstractListener {
void processMessage() { doPMessage(); }
abstract void doPMessage();
}
class OrderListener extends AbstractListener {
void doPMessage() {}
}
class SpecialOrderListener extends OrderListener {
void onMessage() { processMessage(); }
}
class StateMachine { void sendEvent() {} }
""");
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
CallGraphPathFinder pathFinder = new CallGraphPathFinder(context);
Map<String, List<CallEdge>> graph = new HashMap<>();
graph.put("com.example.SpecialOrderListener.onMessage", List.of(
new CallEdge("com.example.AbstractListener.processMessage", List.of())));
graph.put("com.example.AbstractListener.processMessage", List.of(
new CallEdge("com.example.OrderListener.doPMessage", List.of()),
new CallEdge("com.example.DocumentListener.doPMessage", List.of())));
graph.put("com.example.OrderListener.doPMessage", List.of(
new CallEdge("com.example.StateMachine.sendEvent", List.of())));
List<List<String>> paths = pathFinder.findAllPaths(
"com.example.SpecialOrderListener.onMessage",
"com.example.StateMachine.sendEvent",
graph,
new HashSet<>(),
"com.example.SpecialOrderListener");
assertThat(paths).hasSize(1);
assertThat(paths.get(0)).contains("com.example.OrderListener.doPMessage");
}
} }

View File

@@ -70,12 +70,13 @@ class ImplicitThisTemplateMethodCallGraphTest {
.as("OrderListener.onMessage -> inherited processMessage -> OrderListener.doPMessage -> sendEvent") .as("OrderListener.onMessage -> inherited processMessage -> OrderListener.doPMessage -> sendEvent")
.isNotEmpty(); .isNotEmpty();
assertThat(orderChains) assertThat(orderChains)
.as("at least one path must go through this listener's hook, not only sibling subclasses") .as("context-sensitive search must never walk sibling DocumentListener frames")
.anyMatch(chain -> chain.getMethodChain().stream() .allSatisfy(chain -> assertThat(chain.getMethodChain())
.noneMatch(hop -> hop.contains("DocumentListener")));
assertThat(orderChains)
.allSatisfy(chain -> assertThat(chain.getMethodChain())
.anyMatch(hop -> hop.contains("OrderListener.doPMessage"))); .anyMatch(hop -> hop.contains("OrderListener.doPMessage")));
assertThat(orderChains.stream() assertThat(orderChains.stream()
.filter(chain -> chain.getMethodChain().stream()
.anyMatch(hop -> hop.contains("OrderListener.doPMessage")))
.flatMap(chain -> { .flatMap(chain -> {
List<String> events = chain.getTriggerPoint().getPolymorphicEvents(); List<String> events = chain.getTriggerPoint().getPolymorphicEvents();
return events == null ? java.util.stream.Stream.<String>empty() : events.stream(); return events == null ? java.util.stream.Stream.<String>empty() : events.stream();
@@ -97,11 +98,12 @@ class ImplicitThisTemplateMethodCallGraphTest {
assertThat(documentChains).isNotEmpty(); assertThat(documentChains).isNotEmpty();
assertThat(documentChains) assertThat(documentChains)
.anyMatch(chain -> chain.getMethodChain().stream() .allSatisfy(chain -> assertThat(chain.getMethodChain())
.noneMatch(hop -> hop.contains("OrderListener")));
assertThat(documentChains)
.allSatisfy(chain -> assertThat(chain.getMethodChain())
.anyMatch(hop -> hop.contains("DocumentListener.doPMessage"))); .anyMatch(hop -> hop.contains("DocumentListener.doPMessage")));
assertThat(documentChains.stream() assertThat(documentChains.stream()
.filter(chain -> chain.getMethodChain().stream()
.anyMatch(hop -> hop.contains("DocumentListener.doPMessage")))
.flatMap(chain -> { .flatMap(chain -> {
List<String> events = chain.getTriggerPoint().getPolymorphicEvents(); List<String> events = chain.getTriggerPoint().getPolymorphicEvents();
return events == null ? java.util.stream.Stream.<String>empty() : events.stream(); return events == null ? java.util.stream.Stream.<String>empty() : events.stream();

View File

@@ -327,4 +327,29 @@ class CodebaseContextTest {
assertThat(context.findEntryPointClasses(List.of("EnableStateMachine"))).hasSize(1); assertThat(context.findEntryPointClasses(List.of("EnableStateMachine"))).hasSize(1);
} }
@Test
void isSubtypeOrSameShouldWalkSuperclassAndInterfaces() throws IOException {
Files.writeString(tempDir.resolve("Hierarchy.java"), """
package com.example;
interface Named {}
abstract class AbstractListener implements Named {}
class OrderListener extends AbstractListener {}
class DocumentListener extends AbstractListener {}
class SpecialOrderListener extends OrderListener {}
""");
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
assertThat(context.isSubtypeOrSame("com.example.OrderListener", "com.example.OrderListener")).isTrue();
assertThat(context.isSubtypeOrSame("com.example.OrderListener", "com.example.AbstractListener")).isTrue();
assertThat(context.isSubtypeOrSame("com.example.OrderListener", "com.example.Named")).isTrue();
assertThat(context.isSubtypeOrSame("com.example.SpecialOrderListener", "com.example.OrderListener")).isTrue();
assertThat(context.isSubtypeOrSame("com.example.OrderListener", "com.example.DocumentListener")).isFalse();
assertThat(context.isSubtypeOrSame("com.example.DocumentListener", "com.example.OrderListener")).isFalse();
assertThat(context.isConcreteClassType("com.example.OrderListener")).isTrue();
assertThat(context.isConcreteClassType("com.example.AbstractListener")).isFalse();
assertThat(context.isConcreteClassType("com.example.Named")).isFalse();
}
} }