From 232328a7c87db4b0aeb747cb26eab76e11beaaa2 Mon Sep 17 00:00:00 2001 From: Kamil Patryk Kozakowski Date: Fri, 17 Jul 2026 04:52:26 +0200 Subject: [PATCH] A --- .../analysis/service/CallGraphPathFinder.java | 47 ++++++++-- .../service/CallGraphPathFinderTest.java | 55 ++++++++++++ ...laboratorAbstractServiceCallGraphTest.java | 85 +++++++++++++++++++ 3 files changed, 178 insertions(+), 9 deletions(-) create mode 100644 state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/RestCollaboratorAbstractServiceCallGraphTest.java 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 9b7b9d4..e2c99e4 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 @@ -213,12 +213,12 @@ public class CallGraphPathFinder { List impls = context.getImplementations(className); if (impls != null) { for (String impl : impls) { - if (!acceptImplementationFallback(impl, selfAtStart)) { + if (!acceptImplementationFallback(className, impl, selfAtStart)) { continue; } String implMethod = impl + "." + methodName; if (graph.containsKey(implMethod)) { - String nextSelf = nextConcreteSelfForImpl(selfAtStart, impl); + String nextSelf = nextConcreteSelfForImpl(selfAtStart, className, impl); List> implPaths = findAllPaths(implMethod, target, graph, visited, localHitCycle, nextSelf); for (List implPath : implPaths) { @@ -353,12 +353,12 @@ public class CallGraphPathFinder { List impls = context.getImplementations(className); if (impls != null) { for (String impl : impls) { - if (!acceptImplementationFallback(impl, concreteSelfFqn)) { + if (!acceptImplementationFallback(className, impl, concreteSelfFqn)) { continue; } String implMethod = impl + "." + methodName; if (graph.containsKey(implMethod)) { - String nextSelf = nextConcreteSelfForImpl(concreteSelfFqn, impl); + String nextSelf = nextConcreteSelfForImpl(concreteSelfFqn, className, impl); List> implPaths = findAllPathsConstrained( implMethod, target, graph, visited, parentHitCycle, bindingEvaluator, bindings, nextSelf); @@ -377,10 +377,13 @@ public class CallGraphPathFinder { /** * 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. + * concrete runtime type — but only for self-hierarchy template dispatch + * ({@code concreteSelf ≤ callerClass}). Collaborator abstract types + * ({@code Controller → AbstractService → Impl}) must stay CHA-open; otherwise REST endpoints + * lose every service-side path. */ private boolean acceptVirtualHop(String callerMethod, String targetMethod, String concreteSelfFqn) { - if (!isPruningActive(concreteSelfFqn)) { + if (!isSelfHierarchyDispatch(callerMethod, concreteSelfFqn)) { return true; } if (!isVirtualDispatchCandidate(callerMethod, targetMethod)) { @@ -390,10 +393,15 @@ public class CallGraphPathFinder { return targetClass != null && context.isSubtypeOrSame(concreteSelfFqn, targetClass); } - private boolean acceptImplementationFallback(String implClassFqn, String concreteSelfFqn) { + private boolean acceptImplementationFallback( + String abstractClassFqn, String implClassFqn, String concreteSelfFqn) { if (!isPruningActive(concreteSelfFqn)) { return true; } + // Only prune when the abstract/interface frame is part of the entry's own hierarchy. + if (abstractClassFqn == null || !context.isSubtypeOrSame(concreteSelfFqn, abstractClassFqn)) { + return true; + } return context.isSubtypeOrSame(concreteSelfFqn, implClassFqn); } @@ -403,6 +411,18 @@ public class CallGraphPathFinder { && context.isConcreteClassType(concreteSelfFqn); } + /** + * True when {@code concreteSelf} is a concrete subtype of the caller frame — i.e. we are + * walking inherited/template-method code belonging to the entry instance, not a collaborator. + */ + private boolean isSelfHierarchyDispatch(String callerMethod, String concreteSelfFqn) { + if (!isPruningActive(concreteSelfFqn)) { + return false; + } + String callerClass = classNameOf(callerMethod); + return callerClass != null && context.isSubtypeOrSame(concreteSelfFqn, callerClass); + } + /** * An edge from an abstract/interface declaring type into one of its subtypes/implementations * is a virtual-dispatch fan-out (template-method hook expansion). @@ -429,7 +449,10 @@ public class CallGraphPathFinder { } private String nextConcreteSelf(String concreteSelfFqn, String callerMethod, String targetMethod) { - if (!isVirtualDispatchCandidate(callerMethod, targetMethod)) { + // Only narrow self when dispatching within the entry hierarchy; never adopt a collaborator + // impl type as the new "this". + if (!isSelfHierarchyDispatch(callerMethod, concreteSelfFqn) + || !isVirtualDispatchCandidate(callerMethod, targetMethod)) { return concreteSelfFqn; } String targetClass = classNameOf(targetMethod); @@ -439,7 +462,13 @@ public class CallGraphPathFinder { return concreteSelfFqn; } - private String nextConcreteSelfForImpl(String concreteSelfFqn, String implClassFqn) { + private String nextConcreteSelfForImpl( + String concreteSelfFqn, String abstractClassFqn, String implClassFqn) { + if (!isPruningActive(concreteSelfFqn) + || abstractClassFqn == null + || !context.isSubtypeOrSame(concreteSelfFqn, abstractClassFqn)) { + return concreteSelfFqn; + } if (implClassFqn != null && context.isConcreteClassType(implClassFqn)) { return implClassFqn; } 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 7c14a2b..5caca50 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 @@ -218,4 +218,59 @@ class CallGraphPathFinderTest { assertThat(paths).hasSize(1); assertThat(paths.get(0)).contains("com.example.OrderListener.doPMessage"); } + + @Test + void shouldNotPruneCollaboratorAbstractServiceHopsForRestControllerEntry() throws Exception { + Path tempDir = Files.createTempDirectory("pathfinder_rest_collaborator"); + Files.writeString(tempDir.resolve("Rest.java"), """ + package com.example; + class OrderController { + void pay() { shared.process(); } + } + abstract class AbstractSharedService { + void process() { doWork(); } + abstract void doWork(); + } + class OrderSharedService extends AbstractSharedService { + StateMachine sm = new StateMachine(); + void doWork() { sm.sendEvent(); } + } + class DocumentSharedService extends AbstractSharedService { + StateMachine sm = new StateMachine(); + void doWork() { sm.sendEvent(); } + } + class StateMachine { void sendEvent() {} } + """); + + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + CallGraphPathFinder pathFinder = new CallGraphPathFinder(context); + + // Controller entry self is NOT in AbstractSharedService hierarchy — pruning must fail open + // so REST → shared abstract service still reaches impl hooks. + Map> graph = new HashMap<>(); + graph.put("com.example.OrderController.pay", List.of( + new CallEdge("com.example.AbstractSharedService.process", List.of()))); + graph.put("com.example.AbstractSharedService.process", List.of( + new CallEdge("com.example.OrderSharedService.doWork", List.of()), + new CallEdge("com.example.DocumentSharedService.doWork", List.of()))); + graph.put("com.example.OrderSharedService.doWork", List.of( + new CallEdge("com.example.StateMachine.sendEvent", List.of()))); + graph.put("com.example.DocumentSharedService.doWork", List.of( + new CallEdge("com.example.StateMachine.sendEvent", List.of()))); + + List> paths = pathFinder.findAllPaths( + "com.example.OrderController.pay", + "com.example.StateMachine.sendEvent", + graph, + new HashSet<>(), + "com.example.OrderController"); + + assertThat(paths) + .as("REST controller entry must still reach collaborator abstract-service impls") + .hasSize(2); + assertThat(paths.stream().flatMap(List::stream)) + .anyMatch(hop -> hop.contains("OrderSharedService.doWork")) + .anyMatch(hop -> hop.contains("DocumentSharedService.doWork")); + } } diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/RestCollaboratorAbstractServiceCallGraphTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/RestCollaboratorAbstractServiceCallGraphTest.java new file mode 100644 index 0000000..79ec9ce --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/RestCollaboratorAbstractServiceCallGraphTest.java @@ -0,0 +1,85 @@ +package click.kamil.springstatemachineexporter.analysis.service; + +import click.kamil.springstatemachineexporter.analysis.model.CallChain; +import click.kamil.springstatemachineexporter.analysis.model.EntryPoint; +import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * REST controllers call into shared abstract services. Context-sensitive pruning must only + * apply to the entry's own type hierarchy — not collaborator abstract types — or every REST + * chain dies at {@code AbstractService.doWork()} fan-out. + */ +class RestCollaboratorAbstractServiceCallGraphTest { + + @Test + void restControllerStillReachesSharedAbstractServiceTrigger(@TempDir Path tempDir) throws Exception { + Files.writeString(tempDir.resolve("App.java"), """ + package com.example; + class OrderController { + private final OrderSharedService service = new OrderSharedService(); + public void pay() { + service.process(OrderEvent.PAY); + } + } + abstract class AbstractSharedService { + protected abstract void fire(OrderEvent event); + void process(OrderEvent event) { + fire(event); + } + } + class OrderSharedService extends AbstractSharedService { + private final StateMachine sm = new StateMachine(); + @Override + protected void fire(OrderEvent event) { + sm.sendEvent(event); + } + } + class DocumentSharedService extends AbstractSharedService { + private final StateMachine sm = new StateMachine(); + @Override + protected void fire(OrderEvent event) { + sm.sendEvent(event); + } + } + class StateMachine { + void sendEvent(OrderEvent event) {} + } + enum OrderEvent { PAY } + """); + + CodebaseContext context = new CodebaseContext(); + context.setResolveBindings(true); + context.scan(tempDir); + + HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context); + List chains = engine.findChains( + List.of(EntryPoint.builder() + .type(EntryPoint.Type.REST) + .name("POST /orders/pay") + .className("com.example.OrderController") + .methodName("pay") + .build()), + List.of(TriggerPoint.builder() + .className("com.example.StateMachine") + .methodName("sendEvent") + .event("event") + .build())); + + assertThat(chains) + .as("REST → AbstractSharedService.process → fire() must remain reachable") + .isNotEmpty(); + assertThat(chains) + .anyMatch(chain -> chain.getMethodChain().stream() + .anyMatch(hop -> hop.contains("OrderSharedService.fire") + || hop.contains("sendEvent"))); + } +}