A
This commit is contained in:
@@ -2,24 +2,45 @@ package click.kamil.springstatemachineexporter.analysis.model;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.AllArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class CallEdge {
|
||||
private String targetMethod;
|
||||
private List<String> arguments;
|
||||
private String receiver;
|
||||
private String constraint;
|
||||
/**
|
||||
* Proven or CHA-refined receiver type FQN when known (field/param type, unique injection,
|
||||
* or the concrete impl class of a polymorphic expansion target). Used by pathfinding to
|
||||
* prune collaborator template-method fan-out; null means fail open.
|
||||
*/
|
||||
private String receiverTypeFqn;
|
||||
|
||||
public CallEdge(String targetMethod, List<String> arguments) {
|
||||
this(targetMethod, arguments, null, null);
|
||||
this(targetMethod, arguments, null, null, null);
|
||||
}
|
||||
|
||||
public CallEdge(String targetMethod, List<String> arguments, String receiver) {
|
||||
this(targetMethod, arguments, receiver, null);
|
||||
this(targetMethod, arguments, receiver, null, null);
|
||||
}
|
||||
|
||||
public CallEdge(String targetMethod, List<String> arguments, String receiver, String constraint) {
|
||||
this(targetMethod, arguments, receiver, constraint, null);
|
||||
}
|
||||
|
||||
public CallEdge(
|
||||
String targetMethod,
|
||||
List<String> arguments,
|
||||
String receiver,
|
||||
String constraint,
|
||||
String receiverTypeFqn) {
|
||||
this.targetMethod = targetMethod;
|
||||
this.arguments = arguments;
|
||||
this.receiver = receiver;
|
||||
this.constraint = constraint;
|
||||
this.receiverTypeFqn = receiverTypeFqn;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1375,6 +1375,54 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
return receiver.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort type of the call receiver for call-graph edges. Implicit {@code this} uses the
|
||||
* enclosing type; simple names use field/param/local resolution.
|
||||
*/
|
||||
protected String resolveReceiverTypeFqn(Expression receiver, TypeDeclaration enclosingType) {
|
||||
if (receiver == null || receiver instanceof ThisExpression) {
|
||||
return enclosingType != null ? context.getFqn(enclosingType) : null;
|
||||
}
|
||||
if (receiver instanceof SimpleName sn) {
|
||||
return resolveReceiverTypeFallback(sn);
|
||||
}
|
||||
if (receiver instanceof FieldAccess fa) {
|
||||
return resolveReceiverTypeFallback(fa.getName());
|
||||
}
|
||||
if (receiver instanceof MethodInvocation mi) {
|
||||
return resolveMethodInvocationReturnType(mi);
|
||||
}
|
||||
ITypeBinding binding = receiver.resolveTypeBinding();
|
||||
if (binding != null) {
|
||||
return binding.getErasure().getQualifiedName();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* For CHA-expanded targets {@code Impl.method}, prefer the concrete impl class as the edge's
|
||||
* receiver type so pathfinding can prune sibling overrides inside that collaborator hierarchy.
|
||||
*/
|
||||
protected String refineReceiverTypeForTarget(String declaredReceiverTypeFqn, String calledMethodFqn) {
|
||||
if (calledMethodFqn == null || !calledMethodFqn.contains(".")) {
|
||||
return declaredReceiverTypeFqn;
|
||||
}
|
||||
String targetClass = calledMethodFqn.substring(0, calledMethodFqn.lastIndexOf('.'));
|
||||
if (targetClass.contains("<")) {
|
||||
targetClass = targetClass.substring(0, targetClass.indexOf('<'));
|
||||
}
|
||||
if (!context.isConcreteClassType(targetClass)) {
|
||||
return declaredReceiverTypeFqn;
|
||||
}
|
||||
if (declaredReceiverTypeFqn == null
|
||||
|| declaredReceiverTypeFqn.isBlank()
|
||||
|| context.isSubtypeOrSame(targetClass, declaredReceiverTypeFqn)
|
||||
|| context.areSameTypeOrUnambiguousSimpleMatch(targetClass, declaredReceiverTypeFqn)) {
|
||||
return targetClass;
|
||||
}
|
||||
return declaredReceiverTypeFqn;
|
||||
}
|
||||
|
||||
protected String findCallerReceiver(String callerFqn, String targetMethodFqn) {
|
||||
int lastDot = callerFqn.lastIndexOf('.');
|
||||
if (lastDot < 0) {
|
||||
@@ -2545,9 +2593,14 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
List<String> args = resolveArguments(node.arguments());
|
||||
String constraint = click.kamil.springstatemachineexporter.ast.common.AstUtils
|
||||
.findConditionConstraint(node);
|
||||
for (String calledMethod : calledMethods) {
|
||||
String receiver = getReceiverString(node.getExpression());
|
||||
CallEdge edge = new CallEdge(calledMethod, args, receiver);
|
||||
String declaredReceiverType =
|
||||
resolveReceiverTypeFqn(node.getExpression(), td);
|
||||
for (String calledMethod : calledMethods) {
|
||||
String receiverTypeFqn =
|
||||
refineReceiverTypeForTarget(declaredReceiverType, calledMethod);
|
||||
CallEdge edge = new CallEdge(
|
||||
calledMethod, args, receiver, null, receiverTypeFqn);
|
||||
edge.setConstraint(resolveConstraint(node, calledMethod, constraint));
|
||||
graph.computeIfAbsent(callerFqn, k -> new ArrayList<>()).add(edge);
|
||||
}
|
||||
@@ -2566,9 +2619,13 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
} else {
|
||||
implicitArgs.addAll(args);
|
||||
}
|
||||
String receiver = getReceiverString(node.getExpression());
|
||||
CallEdge edge = new CallEdge(refMethod, implicitArgs, receiver);
|
||||
edge.setConstraint(constraint);
|
||||
CallEdge edge = new CallEdge(
|
||||
refMethod,
|
||||
implicitArgs,
|
||||
receiver,
|
||||
constraint,
|
||||
refineReceiverTypeForTarget(
|
||||
declaredReceiverType, refMethod));
|
||||
graph.computeIfAbsent(callerFqn, k -> new ArrayList<>()).add(edge);
|
||||
}
|
||||
}
|
||||
@@ -2589,16 +2646,24 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
} else {
|
||||
implicitArgs.addAll(args);
|
||||
}
|
||||
String receiver = getReceiverString(node.getExpression());
|
||||
CallEdge edge = new CallEdge(calledMethod, implicitArgs, receiver);
|
||||
edge.setConstraint(constraint);
|
||||
String emrReceiver = getReceiverString(emr.getExpression());
|
||||
CallEdge edge = new CallEdge(
|
||||
calledMethod,
|
||||
implicitArgs,
|
||||
emrReceiver,
|
||||
constraint,
|
||||
refineReceiverTypeForTarget(fallbackTypeFqn, calledMethod));
|
||||
graph.computeIfAbsent(callerFqn, k -> new ArrayList<>()).add(edge);
|
||||
|
||||
List<String> impls = context.getImplementations(fallbackTypeFqn);
|
||||
for (String impl : impls) {
|
||||
String implMethod = impl + "." + emr.getName().getIdentifier();
|
||||
CallEdge implEdge = new CallEdge(
|
||||
impl + "." + emr.getName().getIdentifier(), args, receiver);
|
||||
implEdge.setConstraint(constraint);
|
||||
implMethod,
|
||||
args,
|
||||
emrReceiver,
|
||||
constraint,
|
||||
refineReceiverTypeForTarget(fallbackTypeFqn, implMethod));
|
||||
graph.computeIfAbsent(callerFqn, k -> new ArrayList<>()).add(implEdge);
|
||||
}
|
||||
}
|
||||
@@ -2625,8 +2690,10 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
String receiver = "super";
|
||||
String constraint = click.kamil.springstatemachineexporter.ast.common.AstUtils
|
||||
.findConditionConstraint(node);
|
||||
CallEdge edge = new CallEdge(calledMethod, args, receiver);
|
||||
edge.setConstraint(constraint);
|
||||
String receiverTypeFqn = refineReceiverTypeForTarget(
|
||||
context.getFqn(tdOuter), calledMethod);
|
||||
CallEdge edge = new CallEdge(
|
||||
calledMethod, args, receiver, constraint, receiverTypeFqn);
|
||||
graph.computeIfAbsent(callerFqn, k -> new ArrayList<>()).add(edge);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,10 +163,12 @@ public class CallGraphPathFinder {
|
||||
|| neighbor.startsWith("org.springframework.")) {
|
||||
continue;
|
||||
}
|
||||
// Prune against the current dispatch self first; only then adopt a stamped
|
||||
// collaborator receiver type for deeper hops.
|
||||
if (!acceptVirtualHop(start, neighbor, selfAtStart)) {
|
||||
continue;
|
||||
}
|
||||
String nextSelf = nextConcreteSelf(selfAtStart, start, neighbor);
|
||||
String nextSelf = nextSelfAfterEdge(selfAtStart, start, edge);
|
||||
if (neighbor.equals(target) || isHeuristicMatch(neighbor, target)) {
|
||||
List<String> path = new ArrayList<>(List.of(start, target));
|
||||
result.add(path);
|
||||
@@ -279,6 +281,7 @@ public class CallGraphPathFinder {
|
||||
if (!acceptVirtualHop(start, neighbor, selfAtStart)) {
|
||||
continue;
|
||||
}
|
||||
String nextSelf = nextSelfAfterEdge(selfAtStart, start, edge);
|
||||
|
||||
String resolvedTarget = neighbor.equals(target) || isHeuristicMatch(neighbor, target) ? target : neighbor;
|
||||
List<String> hopPath = new ArrayList<>(List.of(start, resolvedTarget));
|
||||
@@ -288,7 +291,6 @@ public class CallGraphPathFinder {
|
||||
continue;
|
||||
}
|
||||
|
||||
String nextSelf = nextConcreteSelf(selfAtStart, start, neighbor);
|
||||
if (resolvedTarget.equals(target)) {
|
||||
result.add(hopPath);
|
||||
} else {
|
||||
@@ -375,12 +377,27 @@ public class CallGraphPathFinder {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefer a concrete {@link CallEdge#getReceiverTypeFqn()} stamped at graph construction
|
||||
* (CHA-refined collaborator impl). Otherwise fall back to self-hierarchy narrowing.
|
||||
*/
|
||||
private String nextSelfAfterEdge(String concreteSelfFqn, String callerMethod, CallEdge edge) {
|
||||
if (edge != null) {
|
||||
String stamped = edge.getReceiverTypeFqn();
|
||||
if (stamped != null && context.isConcreteClassType(stamped)) {
|
||||
return stamped;
|
||||
}
|
||||
}
|
||||
String targetMethod = edge != null ? edge.getTargetMethod() : null;
|
||||
return nextConcreteSelf(concreteSelfFqn, callerMethod, targetMethod);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject CHA fan-out edges that dispatch to a sibling subclass incompatible with the entry's
|
||||
* 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.
|
||||
* ({@code Controller → AbstractService → Impl}) must stay CHA-open unless a concrete
|
||||
* {@code receiverTypeFqn} was adopted as the dispatch self on the incoming edge.
|
||||
*/
|
||||
private boolean acceptVirtualHop(String callerMethod, String targetMethod, String concreteSelfFqn) {
|
||||
if (!isSelfHierarchyDispatch(callerMethod, concreteSelfFqn)) {
|
||||
|
||||
@@ -246,8 +246,8 @@ class CallGraphPathFinderTest {
|
||||
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.
|
||||
// Controller entry self is NOT in AbstractSharedService hierarchy — without stamped
|
||||
// receiver types, pruning must fail open so REST 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())));
|
||||
@@ -273,4 +273,59 @@ class CallGraphPathFinderTest {
|
||||
.anyMatch(hop -> hop.contains("OrderSharedService.doWork"))
|
||||
.anyMatch(hop -> hop.contains("DocumentSharedService.doWork"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldPruneCollaboratorTemplateFanOutWhenEdgeStampsConcreteReceiverType() throws Exception {
|
||||
Path tempDir = Files.createTempDirectory("pathfinder_stamped_receiver");
|
||||
Files.writeString(tempDir.resolve("Ops.java"), """
|
||||
package com.example;
|
||||
class OrderJmsListener {
|
||||
void onMessage() { ops.process(); }
|
||||
AbstractOps ops;
|
||||
}
|
||||
abstract class AbstractOps {
|
||||
void process() { fire(); }
|
||||
abstract void fire();
|
||||
}
|
||||
class PayOps extends AbstractOps {
|
||||
void fire() {}
|
||||
}
|
||||
class ShipOps extends AbstractOps {
|
||||
void fire() {}
|
||||
}
|
||||
class StateMachine { void sendEvent() {} }
|
||||
""");
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.scan(tempDir);
|
||||
CallGraphPathFinder pathFinder = new CallGraphPathFinder(context);
|
||||
|
||||
Map<String, List<CallEdge>> graph = new HashMap<>();
|
||||
graph.put("com.example.OrderJmsListener.onMessage", List.of(
|
||||
new CallEdge(
|
||||
"com.example.PayOps.process",
|
||||
List.of(),
|
||||
"ops",
|
||||
null,
|
||||
"com.example.PayOps")));
|
||||
graph.put("com.example.AbstractOps.process", List.of(
|
||||
new CallEdge("com.example.PayOps.fire", List.of()),
|
||||
new CallEdge("com.example.ShipOps.fire", List.of())));
|
||||
graph.put("com.example.PayOps.fire", List.of(
|
||||
new CallEdge("com.example.StateMachine.sendEvent", List.of())));
|
||||
graph.put("com.example.ShipOps.fire", List.of(
|
||||
new CallEdge("com.example.StateMachine.sendEvent", List.of())));
|
||||
|
||||
List<List<String>> paths = pathFinder.findAllPaths(
|
||||
"com.example.OrderJmsListener.onMessage",
|
||||
"com.example.StateMachine.sendEvent",
|
||||
graph,
|
||||
new HashSet<>(),
|
||||
"com.example.OrderJmsListener");
|
||||
|
||||
assertThat(paths).hasSize(1);
|
||||
assertThat(paths.get(0))
|
||||
.contains("com.example.PayOps.fire")
|
||||
.doesNotContain("com.example.ShipOps.fire");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.service;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CallEdge;
|
||||
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 java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* JMS listeners often call a field typed as an abstract collaborator ({@code ops.process()}).
|
||||
* CHA expands to each impl target and must stamp {@code receiverTypeFqn} so pathfinding can
|
||||
* walk that impl's template body without sibling cross-wiring — and still produce edges.
|
||||
*/
|
||||
class JmsCollaboratorAbstractFieldCallGraphTest {
|
||||
|
||||
@Test
|
||||
void jmsListenerThroughAbstractOpsFieldReachesSendEvent(@TempDir Path tempDir) throws Exception {
|
||||
writeFixture(tempDir);
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.setResolveBindings(true);
|
||||
context.scan(tempDir);
|
||||
|
||||
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
|
||||
Map<String, List<CallEdge>> graph = engine.buildCallGraph();
|
||||
|
||||
List<CallEdge> fromOnMessage = graph.get("com.example.OrderJmsListener.onMessage");
|
||||
assertThat(fromOnMessage)
|
||||
.extracting(CallEdge::getTargetMethod)
|
||||
.as("ops.process() must CHA-expand to concrete collaborator impls")
|
||||
.contains(
|
||||
"com.example.PayOps.process",
|
||||
"com.example.ShipOps.process");
|
||||
assertThat(fromOnMessage)
|
||||
.filteredOn(edge -> edge.getTargetMethod().contains("PayOps"))
|
||||
.extracting(CallEdge::getReceiverTypeFqn)
|
||||
.containsOnly("com.example.PayOps");
|
||||
|
||||
List<CallChain> chains = engine.findChains(
|
||||
List.of(EntryPoint.builder()
|
||||
.type(EntryPoint.Type.JMS)
|
||||
.name("JMS: orders")
|
||||
.className("com.example.OrderJmsListener")
|
||||
.methodName("onMessage")
|
||||
.build()),
|
||||
List.of(TriggerPoint.builder()
|
||||
.className("com.example.StateMachine")
|
||||
.methodName("sendEvent")
|
||||
.event("event")
|
||||
.build()));
|
||||
|
||||
assertThat(chains)
|
||||
.as("JMS → abstract ops field → fire → sendEvent must produce chains")
|
||||
.isNotEmpty();
|
||||
|
||||
assertThat(chains)
|
||||
.anyMatch(chain -> chain.getMethodChain().stream()
|
||||
.anyMatch(hop -> hop.contains("PayOps")));
|
||||
assertThat(chains)
|
||||
.filteredOn(chain -> chain.getMethodChain().stream()
|
||||
.anyMatch(hop -> hop.contains("PayOps")))
|
||||
.allSatisfy(chain -> assertThat(chain.getMethodChain())
|
||||
.noneMatch(hop -> hop.contains("ShipOps")));
|
||||
}
|
||||
|
||||
private static void writeFixture(Path tempDir) throws Exception {
|
||||
Files.writeString(tempDir.resolve("JmsOps.java"), """
|
||||
package com.example;
|
||||
|
||||
class OrderJmsListener {
|
||||
private AbstractOps ops;
|
||||
void onMessage(String m) {
|
||||
ops.process(m);
|
||||
}
|
||||
}
|
||||
|
||||
abstract class AbstractOps {
|
||||
void process(String e) {
|
||||
fire(e);
|
||||
}
|
||||
abstract void fire(String e);
|
||||
}
|
||||
|
||||
class PayOps extends AbstractOps {
|
||||
private final StateMachine sm = new StateMachine();
|
||||
@Override
|
||||
void fire(String e) {
|
||||
sm.sendEvent(OrderEvent.PAY);
|
||||
}
|
||||
}
|
||||
|
||||
class ShipOps extends AbstractOps {
|
||||
private final StateMachine sm = new StateMachine();
|
||||
@Override
|
||||
void fire(String e) {
|
||||
sm.sendEvent(OrderEvent.SHIP);
|
||||
}
|
||||
}
|
||||
|
||||
class StateMachine {
|
||||
void sendEvent(Object event) {}
|
||||
}
|
||||
enum OrderEvent { PAY, SHIP }
|
||||
""");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user