This commit is contained in:
2026-07-17 04:52:26 +02:00
parent d4c0de1d0a
commit 232328a7c8
3 changed files with 178 additions and 9 deletions

View File

@@ -213,12 +213,12 @@ public class CallGraphPathFinder {
List<String> 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<List<String>> implPaths =
findAllPaths(implMethod, target, graph, visited, localHitCycle, nextSelf);
for (List<String> implPath : implPaths) {
@@ -353,12 +353,12 @@ public class CallGraphPathFinder {
List<String> 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<List<String>> 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;
}

View File

@@ -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<String, List<CallEdge>> 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<List<String>> 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"));
}
}

View File

@@ -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<CallChain> 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")));
}
}