diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/model/CallEdge.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/model/CallEdge.java index af6f7a4..30ffecb 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/model/CallEdge.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/model/CallEdge.java @@ -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 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 arguments) { - this(targetMethod, arguments, null, null); + this(targetMethod, arguments, null, null, null); } public CallEdge(String targetMethod, List arguments, String receiver) { - this(targetMethod, arguments, receiver, null); + this(targetMethod, arguments, receiver, null, null); + } + + public CallEdge(String targetMethod, List arguments, String receiver, String constraint) { + this(targetMethod, arguments, receiver, constraint, null); + } + + public CallEdge( + String targetMethod, + List arguments, + String receiver, + String constraint, + String receiverTypeFqn) { + this.targetMethod = targetMethod; + this.arguments = arguments; + this.receiver = receiver; + this.constraint = constraint; + this.receiverTypeFqn = receiverTypeFqn; } } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AbstractCallGraphEngine.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AbstractCallGraphEngine.java index 9553269..9facd02 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AbstractCallGraphEngine.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AbstractCallGraphEngine.java @@ -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 args = resolveArguments(node.arguments()); String constraint = click.kamil.springstatemachineexporter.ast.common.AstUtils .findConditionConstraint(node); + String receiver = getReceiverString(node.getExpression()); + String declaredReceiverType = + resolveReceiverTypeFqn(node.getExpression(), td); for (String calledMethod : calledMethods) { - String receiver = getReceiverString(node.getExpression()); - CallEdge edge = new CallEdge(calledMethod, args, receiver); + 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 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); } } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/CallGraphPathFinder.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/CallGraphPathFinder.java index e2c99e4..7c2b27b 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/CallGraphPathFinder.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/CallGraphPathFinder.java @@ -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 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 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)) { diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/CallGraphPathFinderTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/CallGraphPathFinderTest.java index 5caca50..caed6a9 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/CallGraphPathFinderTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/CallGraphPathFinderTest.java @@ -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> 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> 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> 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"); + } } diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/JmsCollaboratorAbstractFieldCallGraphTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/JmsCollaboratorAbstractFieldCallGraphTest.java new file mode 100644 index 0000000..7201e81 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/JmsCollaboratorAbstractFieldCallGraphTest.java @@ -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> graph = engine.buildCallGraph(); + + List 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 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 } + """); + } +}