This commit is contained in:
2026-07-17 07:11:24 +02:00
parent 232328a7c8
commit 828347f80d
5 changed files with 297 additions and 22 deletions

View File

@@ -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;
}
}

View File

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

View File

@@ -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)) {

View File

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

View File

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