A
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user