This commit is contained in:
2026-07-17 07:18:09 +02:00
parent 828347f80d
commit 4cb4ba12e0
13 changed files with 499 additions and 9 deletions

View File

@@ -90,9 +90,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
List<CallChain> chains = new ArrayList<>();
for (EntryPoint ep : entryPoints) {
String startMethod = ep.getClassName() + "." + ep.getMethodName();
List<String> startMethods = new ArrayList<>();
startMethods.add(startMethod);
List<MessagingStartContext> startContexts = resolveMessagingStartContexts(ep, callGraph);
List<Map<String, String>> bindingVariants =
EntryPointBindingExpander.expandEntryPointBindings(ep, context, callGraph);
@@ -109,15 +107,23 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
for (TriggerPoint tp : triggers) {
String targetMethod = tp.getClassName() + "." + tp.getMethodName();
List<List<String>> allPaths = new ArrayList<>();
for (String sMethod : startMethods) {
for (MessagingStartContext startContext : startContexts) {
if (initialBindings.isEmpty()) {
allPaths.addAll(pathFinder.findAllPaths(
sMethod, targetMethod, callGraph, new HashSet<>(),
ep.getClassName()));
startContext.startMethod(),
targetMethod,
callGraph,
new HashSet<>(),
startContext.concreteSelfFqn()));
} else {
allPaths.addAll(pathFinder.findAllPaths(
sMethod, targetMethod, callGraph, new HashSet<>(),
pathBindingEvaluator, initialBindings, ep.getClassName()));
startContext.startMethod(),
targetMethod,
callGraph,
new HashSet<>(),
pathBindingEvaluator,
initialBindings,
startContext.concreteSelfFqn()));
}
}
Set<List<String>> uniquePaths = new LinkedHashSet<>(allPaths);
@@ -146,13 +152,67 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
}
if (!foundAny && log.isDebugEnabled()) {
log.debug("Entry point {} ({}) did not reach any trigger points. Graph nodes available: {}",
chainEntryPoint.getName(), startMethod, callGraph.keySet());
chainEntryPoint.getName(),
startContexts.stream().map(MessagingStartContext::startMethod).toList(),
callGraph.keySet());
}
}
}
return chains;
}
/**
* Messaging entries annotated on abstract/interface types must be searched once per concrete
* runtime listener so template-method pruning has a concrete {@code concreteSelf}.
* REST/CUSTOM entries keep a single start at the declaring type.
*/
private List<MessagingStartContext> resolveMessagingStartContexts(
EntryPoint ep,
Map<String, List<CallEdge>> callGraph) {
String declaringClass = ep.getClassName();
String methodName = ep.getMethodName();
String declaringStart = declaringClass + "." + methodName;
if (!isMessagingEntryType(ep.getType()) || declaringClass == null || methodName == null) {
return List.of(new MessagingStartContext(declaringStart, declaringClass));
}
if (context.isConcreteClassType(declaringClass)) {
return List.of(new MessagingStartContext(declaringStart, declaringClass));
}
List<String> impls = context.getImplementations(declaringClass);
if (impls == null || impls.isEmpty()) {
return List.of(new MessagingStartContext(declaringStart, declaringClass));
}
LinkedHashSet<MessagingStartContext> contexts = new LinkedHashSet<>();
for (String implFqn : impls) {
if (!context.isConcreteClassType(implFqn)) {
continue;
}
String implStart = implFqn + "." + methodName;
String startMethod = callGraph.containsKey(implStart) ? implStart : declaringStart;
contexts.add(new MessagingStartContext(startMethod, implFqn));
}
if (contexts.isEmpty()) {
return List.of(new MessagingStartContext(declaringStart, declaringClass));
}
return List.copyOf(contexts);
}
private static boolean isMessagingEntryType(EntryPoint.Type type) {
return type == EntryPoint.Type.JMS
|| type == EntryPoint.Type.RABBIT
|| type == EntryPoint.Type.KAFKA
|| type == EntryPoint.Type.SQS
|| type == EntryPoint.Type.SNS;
}
private record MessagingStartContext(String startMethod, String concreteSelfFqn) {
}
protected TriggerPoint resolveTriggerPointParameters(TriggerPoint tp, List<String> path, Map<String, List<CallEdge>> callGraph) {
return resolveTriggerPointParameters(tp, path, callGraph, Map.of());
}
@@ -1399,6 +1459,14 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
return null;
}
/**
* When a unique Spring bean can be proven for the receiver, return that concrete type FQN.
* Default: none (Heuristic engine). {@link JdtCallGraphEngine} overrides via injection analysis.
*/
protected String resolveInjectedReceiverTypeFqn(Expression receiver) {
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.
@@ -2596,6 +2664,11 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
String receiver = getReceiverString(node.getExpression());
String declaredReceiverType =
resolveReceiverTypeFqn(node.getExpression(), td);
String injectedReceiverType =
resolveInjectedReceiverTypeFqn(node.getExpression());
if (injectedReceiverType != null) {
declaredReceiverType = injectedReceiverType;
}
for (String calledMethod : calledMethods) {
String receiverTypeFqn =
refineReceiverTypeForTarget(declaredReceiverType, calledMethod);

View File

@@ -139,6 +139,23 @@ public class JdtCallGraphEngine extends AbstractCallGraphEngine {
return null;
}
@Override
protected String resolveInjectedReceiverTypeFqn(Expression receiver) {
if (injectionAnalyzer == null || receiver == null) {
return null;
}
IBinding nameBinding = null;
if (receiver instanceof SimpleName sn) {
nameBinding = sn.resolveBinding();
} else if (receiver instanceof FieldAccess fa) {
nameBinding = fa.resolveFieldBinding();
}
if (!(nameBinding instanceof IVariableBinding varBinding)) {
return null;
}
return injectionAnalyzer.resolveInjectedBeanFqn(varBinding);
}
private Expression unwrapMessageBuilder(Expression expr) {
Expression peeled = ReactiveExpressionSupport.peelFactoryPayloadExpression(expr);
return peeled != null ? peeled : expr;

View File

@@ -0,0 +1,179 @@
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;
/**
* When {@code @JmsListener} is declared on an abstract base / interface, findChains must expand
* to concrete implementations with per-impl {@code concreteSelf} so template hooks reach
* {@code sendEvent} without sibling cross-wiring.
*/
class JmsAbstractEntryExpansionTest {
@Test
void abstractJmsListenerExpandsToConcreteSubclassChains(@TempDir Path tempDir) throws Exception {
Files.writeString(tempDir.resolve("Listeners.java"), """
package com.example;
@interface JmsListener {
String destination() default "";
}
abstract class AbstractAA {
@JmsListener(destination = "orders")
void onMessage(String m) {
processMessage(m);
}
void processMessage(String m) {
doPMessage(m);
}
abstract void doPMessage(String m);
}
class OrderAA extends AbstractAA {
private final StateMachine sm = new StateMachine();
@Override
void doPMessage(String m) {
sm.sendEvent(OrderEvent.PAY);
}
}
class DocumentAA extends AbstractAA {
private final StateMachine sm = new StateMachine();
@Override
void doPMessage(String m) {
sm.sendEvent(DocumentEvent.SUBMIT);
}
}
class StateMachine {
void sendEvent(Object event) {}
}
enum OrderEvent { PAY }
enum DocumentEvent { SUBMIT }
""");
CodebaseContext context = scan(tempDir);
List<EntryPoint> entryPoints = detectJms(context);
assertThat(entryPoints)
.anySatisfy(ep -> assertThat(ep.getClassName()).isEqualTo("com.example.AbstractAA"));
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
List<CallChain> chains = engine.findChains(
entryPoints.stream().filter(ep -> "onMessage".equals(ep.getMethodName())).toList(),
List.of(TriggerPoint.builder()
.className("com.example.StateMachine")
.methodName("sendEvent")
.event("event")
.build()));
assertThat(chains)
.as("abstract JMS EP must expand to concrete subclass paths")
.isNotEmpty();
assertThat(chains)
.anyMatch(chain -> chain.getMethodChain().stream()
.anyMatch(hop -> hop.contains("OrderAA.doPMessage")));
assertThat(chains)
.anyMatch(chain -> chain.getMethodChain().stream()
.anyMatch(hop -> hop.contains("DocumentAA.doPMessage")));
assertThat(chains)
.filteredOn(chain -> chain.getMethodChain().stream()
.anyMatch(hop -> hop.contains("OrderAA.doPMessage")))
.allSatisfy(chain -> assertThat(chain.getMethodChain())
.noneMatch(hop -> hop.contains("DocumentAA")));
assertThat(chains)
.filteredOn(chain -> chain.getMethodChain().stream()
.anyMatch(hop -> hop.contains("DocumentAA.doPMessage")))
.allSatisfy(chain -> assertThat(chain.getMethodChain())
.noneMatch(hop -> hop.contains("OrderAA")));
}
@Test
void interfaceJmsListenerWithMultipleImplsYieldsIsolatedChains(@TempDir Path tempDir) throws Exception {
Files.writeString(tempDir.resolve("Iface.java"), """
package com.example;
@interface JmsListener {
String destination() default "";
}
interface OrderCommands {
@JmsListener(destination = "orders")
void onMessage(String m);
}
class PayListener implements OrderCommands {
private final StateMachine sm = new StateMachine();
@Override
public void onMessage(String m) {
sm.sendEvent(OrderEvent.PAY);
}
}
class ShipListener implements OrderCommands {
private final StateMachine sm = new StateMachine();
@Override
public void onMessage(String m) {
sm.sendEvent(OrderEvent.SHIP);
}
}
class StateMachine {
void sendEvent(Object event) {}
}
enum OrderEvent { PAY, SHIP }
""");
CodebaseContext context = scan(tempDir);
List<EntryPoint> entryPoints = detectJms(context);
assertThat(entryPoints)
.anySatisfy(ep -> assertThat(ep.getClassName()).isEqualTo("com.example.OrderCommands"));
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
List<CallChain> chains = engine.findChains(
entryPoints,
List.of(TriggerPoint.builder()
.className("com.example.StateMachine")
.methodName("sendEvent")
.event("event")
.build()));
assertThat(chains).isNotEmpty();
assertThat(chains)
.anyMatch(chain -> chain.getMethodChain().stream()
.anyMatch(hop -> hop.contains("PayListener")));
assertThat(chains)
.anyMatch(chain -> chain.getMethodChain().stream()
.anyMatch(hop -> hop.contains("ShipListener")));
assertThat(chains)
.filteredOn(chain -> chain.getMethodChain().stream()
.anyMatch(hop -> hop.contains("PayListener")))
.allSatisfy(chain -> assertThat(chain.getMethodChain())
.noneMatch(hop -> hop.contains("ShipListener")));
}
private static List<EntryPoint> detectJms(CodebaseContext context) {
MessagingDetector detector = new MessagingDetector(context);
return context.getCompilationUnits().stream()
.flatMap(cu -> detector.detect(cu).stream())
.filter(ep -> ep.getType() == EntryPoint.Type.JMS)
.toList();
}
private static CodebaseContext scan(Path tempDir) throws Exception {
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.scan(tempDir);
return context;
}
}

View File

@@ -0,0 +1,81 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.exporter.JsonExporter;
import click.kamil.springstatemachineexporter.service.ExportService;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
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.stream.StreamSupport;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Export-level gate for {@code state_machines/jms_abstract_template_sample}: abstract
* {@code @JmsListener} must produce JMS callChains through concrete subclass hooks.
*/
class JmsAbstractTemplateSampleExportTest {
@Test
void exportShouldProduceJmsCallChainsThroughConcreteSubclassHooks(@TempDir Path tempDir) throws Exception {
Path root = findProjectRoot();
Path sample = root.resolve("state_machines/jms_abstract_template_sample");
assertThat(sample).isDirectory();
Path outputDir = tempDir.resolve("out");
ExportService exportService = new ExportService(List.of(new JsonExporter()));
exportService.runExporter(
sample,
outputDir,
List.of("json"),
true,
List.of(),
null,
null,
click.kamil.springstatemachineexporter.exporter.EnumFormat.fn,
click.kamil.springstatemachineexporter.exporter.EnumFormat.fn);
Path jsonFile = Files.walk(outputDir)
.filter(path -> path.getFileName().toString().endsWith(".json"))
.findFirst()
.orElseThrow();
JsonNode rootJson = new ObjectMapper().readTree(jsonFile.toFile());
List<JsonNode> callChains = StreamSupport.stream(
rootJson.path("metadata").path("callChains").spliterator(), false)
.toList();
assertThat(callChains)
.as("abstract JMS template sample must export at least one callChain")
.isNotEmpty();
assertThat(callChains)
.anySatisfy(chain -> {
String entry = chain.path("entryPoint").path("name").asText();
assertThat(entry).containsIgnoringCase("JMS");
String methods = chain.path("methodChain").toString();
assertThat(methods).containsAnyOf("OrderAA", "DocumentAA", "AbstractAA", "sendEvent");
});
assertThat(callChains.stream()
.filter(chain -> chain.path("methodChain").toString().contains("OrderAA"))
.toList())
.allSatisfy(chain -> assertThat(chain.path("methodChain").toString())
.doesNotContain("DocumentAA.doPMessage"));
}
private static Path findProjectRoot() {
Path current = Path.of(".").toAbsolutePath().normalize();
while (current != null && !Files.exists(current.resolve("settings.gradle"))) {
current = current.getParent();
}
if (current == null) {
throw new IllegalStateException("Could not find project root");
}
return current;
}
}