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 d8638b5..9553269 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 @@ -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> uniquePaths = new LinkedHashSet<>(allPaths); diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/CallGraphPathFinder.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/CallGraphPathFinder.java index cf6885d..9b7b9d4 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/CallGraphPathFinder.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/CallGraphPathFinder.java @@ -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, Boolean> heuristicCache = new HashMap<>(); + private final Map, Boolean> heuristicCache = new HashMap<>(); - private final Map, List>> memoCache = new HashMap<>(); - private final Map, Boolean> unreachableCache = new HashMap<>(); + /** Memo key: start|target|concreteSelf */ + private final Map>> memoCache = new HashMap<>(); + private final Map 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> findAllPaths(String start, String target, Map> graph, Set visited) { - return findAllPaths(start, target, graph, visited, new boolean[]{false}); + return findAllPaths(start, target, graph, visited, new boolean[]{false}, null); + } + + public List> findAllPaths( + String start, + String target, + Map> graph, + Set visited, + String concreteSelfFqn) { + return findAllPaths(start, target, graph, visited, new boolean[]{false}, concreteSelfFqn); } public List> findAllPaths( @@ -69,17 +86,43 @@ public class CallGraphPathFinder { Set visited, PathBindingEvaluator bindingEvaluator, Map initialBindings) { + return findAllPaths(start, target, graph, visited, bindingEvaluator, initialBindings, null); + } + + public List> findAllPaths( + String start, + String target, + Map> graph, + Set visited, + PathBindingEvaluator bindingEvaluator, + Map initialBindings, + String concreteSelfFqn) { if (bindingEvaluator == null) { - return findAllPaths(start, target, graph, visited); + return findAllPaths(start, target, graph, visited, concreteSelfFqn); } Map 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> findAllPaths(String start, String target, Map> graph, Set visited, boolean[] parentHitCycle) { + public List> findAllPaths( + String start, + String target, + Map> graph, + Set visited, + boolean[] parentHitCycle) { + return findAllPaths(start, target, graph, visited, parentHitCycle, null); + } + + public List> findAllPaths( + String start, + String target, + Map> graph, + Set visited, + boolean[] parentHitCycle, + String concreteSelfFqn) { List> result = new ArrayList<>(); - java.util.Map.Entry 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 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 path = new ArrayList<>(List.of(start, target)); result.add(path); } else { - List> subPaths = findAllPaths(neighbor, target, graph, visited, localHitCycle); + List> subPaths = + findAllPaths(neighbor, target, graph, visited, localHitCycle, nextSelf); for (List subPath : subPaths) { List 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> superPaths = findAllPaths(superMethod, target, graph, visited, localHitCycle); + List> superPaths = + findAllPaths(superMethod, target, graph, visited, localHitCycle, selfAtStart); for (List superPath : superPaths) { List 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 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> implPaths = findAllPaths(implMethod, target, graph, visited, localHitCycle); + String nextSelf = nextConcreteSelfForImpl(selfAtStart, impl); + List> implPaths = + findAllPaths(implMethod, target, graph, visited, localHitCycle, nextSelf); for (List implPath : implPaths) { List 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 visited, boolean[] parentHitCycle, PathBindingEvaluator bindingEvaluator, - Map bindings) { + Map bindings, + String concreteSelfFqn) { List> 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 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> subPaths = findAllPathsConstrained( - neighbor, target, graph, visited, parentHitCycle, bindingEvaluator, branchBindings); + neighbor, target, graph, visited, parentHitCycle, bindingEvaluator, branchBindings, + nextSelf); for (List 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 visited, boolean[] parentHitCycle, PathBindingEvaluator bindingEvaluator, - Map bindings) { + Map bindings, + String concreteSelfFqn) { List> 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> superPaths = findAllPathsConstrained( - superMethod, target, graph, visited, parentHitCycle, bindingEvaluator, bindings); + superMethod, target, graph, visited, parentHitCycle, bindingEvaluator, bindings, + concreteSelfFqn); for (List superPath : superPaths) { List path = new ArrayList<>(); path.add(start); @@ -294,10 +353,15 @@ public class CallGraphPathFinder { List 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> implPaths = findAllPathsConstrained( - implMethod, target, graph, visited, parentHitCycle, bindingEvaluator, bindings); + implMethod, target, graph, visited, parentHitCycle, bindingEvaluator, bindings, + nextSelf); for (List implPath : implPaths) { List 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 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 path, Map> callGraph) { for (String node : path) { List 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 cacheKey = new java.util.AbstractMap.SimpleImmutableEntry<>(neighbor, target); + Map.Entry 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; 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 5128880..642ac92 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 @@ -69,6 +69,7 @@ public class CodebaseContext { private final Map methodDeclarationCache = new HashMap<>(); private final Map superclassFqnCache = new HashMap<>(); private final Map> implementationsCache = new HashMap<>(); + private final Map subtypeOrSameCache = new HashMap<>(); private boolean inlineAccessors = true; public Map getCache() { @@ -107,6 +108,7 @@ public class CodebaseContext { methodDeclarationCache.clear(); superclassFqnCache.clear(); classCompatibilityCache.clear(); + subtypeOrSameCache.clear(); } public Set 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 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(); diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/CallGraphPathFinderTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/CallGraphPathFinderTest.java index 742ca4f..7c14a2b 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/CallGraphPathFinderTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/CallGraphPathFinderTest.java @@ -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> 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> withoutSelf = pathFinder.findAllPaths( + "com.example.OrderListener.onMessage", + "com.example.StateMachine.sendEvent", + graph, + new HashSet<>()); + assertThat(withoutSelf).hasSize(2); + + List> 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> 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> 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"); + } } diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/ImplicitThisTemplateMethodCallGraphTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/ImplicitThisTemplateMethodCallGraphTest.java index 3388c98..fe25f83 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/ImplicitThisTemplateMethodCallGraphTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/ImplicitThisTemplateMethodCallGraphTest.java @@ -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 events = chain.getTriggerPoint().getPolymorphicEvents(); return events == null ? java.util.stream.Stream.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 events = chain.getTriggerPoint().getPolymorphicEvents(); return events == null ? java.util.stream.Stream.empty() : events.stream(); 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 38fb925..ad54df3 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 @@ -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(); + } }