A
This commit is contained in:
@@ -213,12 +213,12 @@ public class CallGraphPathFinder {
|
|||||||
List<String> impls = context.getImplementations(className);
|
List<String> impls = context.getImplementations(className);
|
||||||
if (impls != null) {
|
if (impls != null) {
|
||||||
for (String impl : impls) {
|
for (String impl : impls) {
|
||||||
if (!acceptImplementationFallback(impl, selfAtStart)) {
|
if (!acceptImplementationFallback(className, impl, selfAtStart)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
String implMethod = impl + "." + methodName;
|
String implMethod = impl + "." + methodName;
|
||||||
if (graph.containsKey(implMethod)) {
|
if (graph.containsKey(implMethod)) {
|
||||||
String nextSelf = nextConcreteSelfForImpl(selfAtStart, impl);
|
String nextSelf = nextConcreteSelfForImpl(selfAtStart, className, impl);
|
||||||
List<List<String>> implPaths =
|
List<List<String>> implPaths =
|
||||||
findAllPaths(implMethod, target, graph, visited, localHitCycle, nextSelf);
|
findAllPaths(implMethod, target, graph, visited, localHitCycle, nextSelf);
|
||||||
for (List<String> implPath : implPaths) {
|
for (List<String> implPath : implPaths) {
|
||||||
@@ -353,12 +353,12 @@ public class CallGraphPathFinder {
|
|||||||
List<String> impls = context.getImplementations(className);
|
List<String> impls = context.getImplementations(className);
|
||||||
if (impls != null) {
|
if (impls != null) {
|
||||||
for (String impl : impls) {
|
for (String impl : impls) {
|
||||||
if (!acceptImplementationFallback(impl, concreteSelfFqn)) {
|
if (!acceptImplementationFallback(className, impl, concreteSelfFqn)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
String implMethod = impl + "." + methodName;
|
String implMethod = impl + "." + methodName;
|
||||||
if (graph.containsKey(implMethod)) {
|
if (graph.containsKey(implMethod)) {
|
||||||
String nextSelf = nextConcreteSelfForImpl(concreteSelfFqn, impl);
|
String nextSelf = nextConcreteSelfForImpl(concreteSelfFqn, className, impl);
|
||||||
List<List<String>> implPaths = findAllPathsConstrained(
|
List<List<String>> implPaths = findAllPathsConstrained(
|
||||||
implMethod, target, graph, visited, parentHitCycle, bindingEvaluator, bindings,
|
implMethod, target, graph, visited, parentHitCycle, bindingEvaluator, bindings,
|
||||||
nextSelf);
|
nextSelf);
|
||||||
@@ -377,10 +377,13 @@ public class CallGraphPathFinder {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Reject CHA fan-out edges that dispatch to a sibling subclass incompatible with the entry's
|
* 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) {
|
private boolean acceptVirtualHop(String callerMethod, String targetMethod, String concreteSelfFqn) {
|
||||||
if (!isPruningActive(concreteSelfFqn)) {
|
if (!isSelfHierarchyDispatch(callerMethod, concreteSelfFqn)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (!isVirtualDispatchCandidate(callerMethod, targetMethod)) {
|
if (!isVirtualDispatchCandidate(callerMethod, targetMethod)) {
|
||||||
@@ -390,10 +393,15 @@ public class CallGraphPathFinder {
|
|||||||
return targetClass != null && context.isSubtypeOrSame(concreteSelfFqn, targetClass);
|
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)) {
|
if (!isPruningActive(concreteSelfFqn)) {
|
||||||
return true;
|
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);
|
return context.isSubtypeOrSame(concreteSelfFqn, implClassFqn);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -403,6 +411,18 @@ public class CallGraphPathFinder {
|
|||||||
&& context.isConcreteClassType(concreteSelfFqn);
|
&& 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
|
* An edge from an abstract/interface declaring type into one of its subtypes/implementations
|
||||||
* is a virtual-dispatch fan-out (template-method hook expansion).
|
* 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) {
|
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;
|
return concreteSelfFqn;
|
||||||
}
|
}
|
||||||
String targetClass = classNameOf(targetMethod);
|
String targetClass = classNameOf(targetMethod);
|
||||||
@@ -439,7 +462,13 @@ public class CallGraphPathFinder {
|
|||||||
return concreteSelfFqn;
|
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)) {
|
if (implClassFqn != null && context.isConcreteClassType(implClassFqn)) {
|
||||||
return implClassFqn;
|
return implClassFqn;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -218,4 +218,59 @@ class CallGraphPathFinderTest {
|
|||||||
assertThat(paths).hasSize(1);
|
assertThat(paths).hasSize(1);
|
||||||
assertThat(paths.get(0)).contains("com.example.OrderListener.doPMessage");
|
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