A
This commit is contained in:
@@ -112,11 +112,12 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
for (String sMethod : startMethods) {
|
||||
if (initialBindings.isEmpty()) {
|
||||
allPaths.addAll(pathFinder.findAllPaths(
|
||||
sMethod, targetMethod, callGraph, new HashSet<>()));
|
||||
sMethod, targetMethod, callGraph, new HashSet<>(),
|
||||
ep.getClassName()));
|
||||
} else {
|
||||
allPaths.addAll(pathFinder.findAllPaths(
|
||||
sMethod, targetMethod, callGraph, new HashSet<>(),
|
||||
pathBindingEvaluator, initialBindings));
|
||||
pathBindingEvaluator, initialBindings, ep.getClassName()));
|
||||
}
|
||||
}
|
||||
Set<List<String>> uniquePaths = new LinkedHashSet<>(allPaths);
|
||||
|
||||
@@ -2,17 +2,25 @@ package click.kamil.springstatemachineexporter.analysis.service;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CallEdge;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import org.eclipse.jdt.core.dom.TypeDeclaration;
|
||||
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
|
||||
public class CallGraphPathFinder {
|
||||
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<>();
|
||||
private final Map<java.util.Map.Entry<String, String>, Boolean> unreachableCache = new HashMap<>();
|
||||
/** Memo key: start|target|concreteSelf */
|
||||
private final Map<String, List<List<String>>> memoCache = new HashMap<>();
|
||||
private final Map<String, Boolean> unreachableCache = new HashMap<>();
|
||||
|
||||
public CallGraphPathFinder(CodebaseContext context) {
|
||||
this.context = context;
|
||||
@@ -49,17 +57,26 @@ public class CallGraphPathFinder {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug("Path search dead-end at {} when looking for {}", start, target);
|
||||
}
|
||||
|
||||
|
||||
visited.remove(start);
|
||||
return null;
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -69,17 +86,43 @@ public class CallGraphPathFinder {
|
||||
Set<String> visited,
|
||||
PathBindingEvaluator bindingEvaluator,
|
||||
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) {
|
||||
return findAllPaths(start, target, graph, visited);
|
||||
return findAllPaths(start, target, graph, visited, concreteSelfFqn);
|
||||
}
|
||||
Map<String, String> bindings = initialBindings == null ? new HashMap<>() : new HashMap<>(initialBindings);
|
||||
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<>();
|
||||
java.util.Map.Entry<String, String> cacheKey = new java.util.AbstractMap.SimpleImmutableEntry<>(start, target);
|
||||
String cacheKey = memoKey(start, target, concreteSelfFqn);
|
||||
if (unreachableCache.containsKey(cacheKey)) {
|
||||
return result;
|
||||
}
|
||||
@@ -110,17 +153,26 @@ public class CallGraphPathFinder {
|
||||
}
|
||||
|
||||
boolean[] localHitCycle = new boolean[]{false};
|
||||
String selfAtStart = concreteSelfFqn;
|
||||
|
||||
List<CallEdge> neighbors = graph.get(start);
|
||||
if (neighbors != null) {
|
||||
for (CallEdge edge : neighbors) {
|
||||
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)) {
|
||||
List<String> path = new ArrayList<>(List.of(start, target));
|
||||
result.add(path);
|
||||
} 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) {
|
||||
List<String> path = new ArrayList<>();
|
||||
path.add(start);
|
||||
@@ -130,21 +182,19 @@ public class CallGraphPathFinder {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Handle inheritance fallback for concrete class methods not in the graph
|
||||
if (result.isEmpty() && start.contains(".")) {
|
||||
String className = start.substring(0, start.lastIndexOf('.'));
|
||||
String methodName = start.substring(start.lastIndexOf('.') + 1);
|
||||
if (className.contains("<")) {
|
||||
className = className.substring(0, className.indexOf('<'));
|
||||
}
|
||||
String className = classNameOf(start);
|
||||
String methodName = methodNameOf(start);
|
||||
TypeDeclaration td = context.getTypeDeclaration(className);
|
||||
if (td != null) {
|
||||
String superclass = context.getSuperclassFqn(td);
|
||||
if (superclass != null) {
|
||||
String superMethod = superclass + "." + methodName;
|
||||
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) {
|
||||
List<String> path = new ArrayList<>();
|
||||
path.add(start);
|
||||
@@ -158,17 +208,19 @@ public class CallGraphPathFinder {
|
||||
|
||||
// Handle implementation fallback for interfaces/superclasses (interface -> concrete class)
|
||||
if (result.isEmpty() && start.contains(".")) {
|
||||
String className = start.substring(0, start.lastIndexOf('.'));
|
||||
String methodName = start.substring(start.lastIndexOf('.') + 1);
|
||||
if (className.contains("<")) {
|
||||
className = className.substring(0, className.indexOf('<'));
|
||||
}
|
||||
String className = classNameOf(start);
|
||||
String methodName = methodNameOf(start);
|
||||
List<String> impls = context.getImplementations(className);
|
||||
if (impls != null) {
|
||||
for (String impl : impls) {
|
||||
if (!acceptImplementationFallback(impl, selfAtStart)) {
|
||||
continue;
|
||||
}
|
||||
String implMethod = impl + "." + methodName;
|
||||
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) {
|
||||
List<String> path = new ArrayList<>();
|
||||
path.add(start);
|
||||
@@ -180,7 +232,7 @@ public class CallGraphPathFinder {
|
||||
}
|
||||
}
|
||||
visited.remove(start);
|
||||
|
||||
|
||||
if (localHitCycle[0]) {
|
||||
parentHitCycle[0] = true;
|
||||
} else if (result.isEmpty()) {
|
||||
@@ -192,7 +244,7 @@ public class CallGraphPathFinder {
|
||||
}
|
||||
memoCache.put(cacheKey, toCache);
|
||||
}
|
||||
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -203,7 +255,8 @@ public class CallGraphPathFinder {
|
||||
Set<String> visited,
|
||||
boolean[] parentHitCycle,
|
||||
PathBindingEvaluator bindingEvaluator,
|
||||
Map<String, String> bindings) {
|
||||
Map<String, String> bindings,
|
||||
String concreteSelfFqn) {
|
||||
List<List<String>> result = new ArrayList<>();
|
||||
if (start.equals(target)) {
|
||||
result.add(new ArrayList<>(List.of(start)));
|
||||
@@ -214,11 +267,16 @@ public class CallGraphPathFinder {
|
||||
return result;
|
||||
}
|
||||
|
||||
String selfAtStart = concreteSelfFqn;
|
||||
List<CallEdge> neighbors = graph.get(start);
|
||||
if (neighbors != null) {
|
||||
for (CallEdge edge : neighbors) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -230,11 +288,13 @@ public class CallGraphPathFinder {
|
||||
continue;
|
||||
}
|
||||
|
||||
String nextSelf = nextConcreteSelf(selfAtStart, start, neighbor);
|
||||
if (resolvedTarget.equals(target)) {
|
||||
result.add(hopPath);
|
||||
} else {
|
||||
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) {
|
||||
if (subPath.isEmpty() || subPath.get(0).equals(start)) {
|
||||
continue;
|
||||
@@ -250,7 +310,7 @@ public class CallGraphPathFinder {
|
||||
|
||||
if (result.isEmpty() && start.contains(".")) {
|
||||
result.addAll(findInheritanceFallbackPaths(
|
||||
start, target, graph, visited, parentHitCycle, bindingEvaluator, bindings));
|
||||
start, target, graph, visited, parentHitCycle, bindingEvaluator, bindings, selfAtStart));
|
||||
}
|
||||
|
||||
visited.remove(start);
|
||||
@@ -264,13 +324,11 @@ public class CallGraphPathFinder {
|
||||
Set<String> visited,
|
||||
boolean[] parentHitCycle,
|
||||
PathBindingEvaluator bindingEvaluator,
|
||||
Map<String, String> bindings) {
|
||||
Map<String, String> bindings,
|
||||
String concreteSelfFqn) {
|
||||
List<List<String>> result = new ArrayList<>();
|
||||
String className = start.substring(0, start.lastIndexOf('.'));
|
||||
String methodName = start.substring(start.lastIndexOf('.') + 1);
|
||||
if (className.contains("<")) {
|
||||
className = className.substring(0, className.indexOf('<'));
|
||||
}
|
||||
String className = classNameOf(start);
|
||||
String methodName = methodNameOf(start);
|
||||
|
||||
TypeDeclaration td = context.getTypeDeclaration(className);
|
||||
if (td != null) {
|
||||
@@ -279,7 +337,8 @@ public class CallGraphPathFinder {
|
||||
String superMethod = superclass + "." + methodName;
|
||||
if (graph.containsKey(superMethod)) {
|
||||
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) {
|
||||
List<String> path = new ArrayList<>();
|
||||
path.add(start);
|
||||
@@ -294,10 +353,15 @@ public class CallGraphPathFinder {
|
||||
List<String> impls = context.getImplementations(className);
|
||||
if (impls != null) {
|
||||
for (String impl : impls) {
|
||||
if (!acceptImplementationFallback(impl, concreteSelfFqn)) {
|
||||
continue;
|
||||
}
|
||||
String implMethod = impl + "." + methodName;
|
||||
if (graph.containsKey(implMethod)) {
|
||||
String nextSelf = nextConcreteSelfForImpl(concreteSelfFqn, impl);
|
||||
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) {
|
||||
List<String> path = new ArrayList<>();
|
||||
path.add(start);
|
||||
@@ -311,6 +375,99 @@ public class CallGraphPathFinder {
|
||||
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) {
|
||||
for (String node : path) {
|
||||
List<CallEdge> edges = callGraph.get(node);
|
||||
@@ -332,7 +489,7 @@ public class CallGraphPathFinder {
|
||||
if (neighbor == null || target == null) return false;
|
||||
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);
|
||||
if (cached != null) return cached;
|
||||
|
||||
@@ -348,21 +505,21 @@ public class CallGraphPathFinder {
|
||||
int lastDotNeighbor = neighbor.lastIndexOf('.');
|
||||
int lastDotTarget = target.lastIndexOf('.');
|
||||
if (lastDotNeighbor < 0 || lastDotTarget < 0) return false;
|
||||
|
||||
|
||||
String methodNeighbor = neighbor.substring(lastDotNeighbor + 1);
|
||||
String methodTarget = target.substring(lastDotTarget + 1);
|
||||
|
||||
|
||||
if (!methodNeighbor.equals(methodTarget)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
String classNeighbor = neighbor.substring(0, lastDotNeighbor);
|
||||
String classTarget = target.substring(0, lastDotTarget);
|
||||
|
||||
|
||||
if (classNeighbor.equals(classTarget)) return true;
|
||||
|
||||
|
||||
if (context.areSameTypeOrUnambiguousSimpleMatch(classNeighbor, classTarget)) return true;
|
||||
|
||||
|
||||
if (context.areClassesPolymorphicallyCompatible(classNeighbor, classTarget)) {
|
||||
return true;
|
||||
}
|
||||
@@ -373,11 +530,13 @@ public class CallGraphPathFinder {
|
||||
String simpleTarget = target.contains(".") ? target.substring(target.lastIndexOf('.') + 1) : target;
|
||||
String simpleNeighbor = neighbor.contains(".") ? neighbor.substring(neighbor.lastIndexOf('.') + 1) : neighbor;
|
||||
if (!simpleNeighbor.equals(simpleTarget)) return false;
|
||||
|
||||
|
||||
String classNeighbor = neighbor.contains(".") ? neighbor.substring(0, neighbor.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 simpleClassNeighbor = classNeighbor != null && classNeighbor.contains(".") ? classNeighbor.substring(classNeighbor.lastIndexOf('.') + 1) : classNeighbor;
|
||||
String simpleClassTarget = classTarget != null && classTarget.contains(".")
|
||||
? classTarget.substring(classTarget.lastIndexOf('.') + 1) : classTarget;
|
||||
String simpleClassNeighbor = classNeighbor != null && classNeighbor.contains(".")
|
||||
? classNeighbor.substring(classNeighbor.lastIndexOf('.') + 1) : classNeighbor;
|
||||
|
||||
if (simpleClassNeighbor != null && simpleClassTarget != null) {
|
||||
String leftClass = classNeighbor != null ? classNeighbor : simpleClassNeighbor;
|
||||
|
||||
@@ -69,6 +69,7 @@ public class CodebaseContext {
|
||||
private final Map<String, MethodDeclaration> methodDeclarationCache = new HashMap<>();
|
||||
private final Map<String, String> superclassFqnCache = new HashMap<>();
|
||||
private final Map<String, List<String>> implementationsCache = new HashMap<>();
|
||||
private final Map<String, Boolean> subtypeOrSameCache = new HashMap<>();
|
||||
private boolean inlineAccessors = true;
|
||||
|
||||
public Map<String, Object> getCache() {
|
||||
@@ -107,6 +108,7 @@ public class CodebaseContext {
|
||||
methodDeclarationCache.clear();
|
||||
superclassFqnCache.clear();
|
||||
classCompatibilityCache.clear();
|
||||
subtypeOrSameCache.clear();
|
||||
}
|
||||
|
||||
public Set<String> getIndexedTypeFqns() {
|
||||
@@ -739,6 +741,72 @@ public class CodebaseContext {
|
||||
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) {
|
||||
String bindingSuper = null;
|
||||
ITypeBinding binding = td.resolveBinding();
|
||||
|
||||
@@ -5,6 +5,8 @@ import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
@@ -114,4 +116,106 @@ class CallGraphPathFinderTest {
|
||||
"com.acme.BranchA.step",
|
||||
"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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,12 +70,13 @@ class ImplicitThisTemplateMethodCallGraphTest {
|
||||
.as("OrderListener.onMessage -> inherited processMessage -> OrderListener.doPMessage -> sendEvent")
|
||||
.isNotEmpty();
|
||||
assertThat(orderChains)
|
||||
.as("at least one path must go through this listener's hook, not only sibling subclasses")
|
||||
.anyMatch(chain -> chain.getMethodChain().stream()
|
||||
.as("context-sensitive search must never walk sibling DocumentListener frames")
|
||||
.allSatisfy(chain -> assertThat(chain.getMethodChain())
|
||||
.noneMatch(hop -> hop.contains("DocumentListener")));
|
||||
assertThat(orderChains)
|
||||
.allSatisfy(chain -> assertThat(chain.getMethodChain())
|
||||
.anyMatch(hop -> hop.contains("OrderListener.doPMessage")));
|
||||
assertThat(orderChains.stream()
|
||||
.filter(chain -> chain.getMethodChain().stream()
|
||||
.anyMatch(hop -> hop.contains("OrderListener.doPMessage")))
|
||||
.flatMap(chain -> {
|
||||
List<String> events = chain.getTriggerPoint().getPolymorphicEvents();
|
||||
return events == null ? java.util.stream.Stream.<String>empty() : events.stream();
|
||||
@@ -97,11 +98,12 @@ class ImplicitThisTemplateMethodCallGraphTest {
|
||||
|
||||
assertThat(documentChains).isNotEmpty();
|
||||
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")));
|
||||
assertThat(documentChains.stream()
|
||||
.filter(chain -> chain.getMethodChain().stream()
|
||||
.anyMatch(hop -> hop.contains("DocumentListener.doPMessage")))
|
||||
.flatMap(chain -> {
|
||||
List<String> events = chain.getTriggerPoint().getPolymorphicEvents();
|
||||
return events == null ? java.util.stream.Stream.<String>empty() : events.stream();
|
||||
|
||||
@@ -327,4 +327,29 @@ class CodebaseContextTest {
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user