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

@@ -18,6 +18,7 @@ include ':state_machines:inheritance_sample'
include ':state_machines:layered_dispatcher_sample' include ':state_machines:layered_dispatcher_sample'
include ':state_machines:ultimate_ecosystem_sm' include ':state_machines:ultimate_ecosystem_sm'
include ':state_machines:enterprise_order_system' include ':state_machines:enterprise_order_system'
include ':state_machines:jms_abstract_template_sample'
include ':state_machines:multi_module_sample:api-module' include ':state_machines:multi_module_sample:api-module'
include ':state_machines:multi_module_sample:core-module' include ':state_machines:multi_module_sample:core-module'
include ':state_machines:state_machine_enterprise' include ':state_machines:state_machine_enterprise'

View File

@@ -90,9 +90,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
List<CallChain> chains = new ArrayList<>(); List<CallChain> chains = new ArrayList<>();
for (EntryPoint ep : entryPoints) { for (EntryPoint ep : entryPoints) {
String startMethod = ep.getClassName() + "." + ep.getMethodName(); List<MessagingStartContext> startContexts = resolveMessagingStartContexts(ep, callGraph);
List<String> startMethods = new ArrayList<>();
startMethods.add(startMethod);
List<Map<String, String>> bindingVariants = List<Map<String, String>> bindingVariants =
EntryPointBindingExpander.expandEntryPointBindings(ep, context, callGraph); EntryPointBindingExpander.expandEntryPointBindings(ep, context, callGraph);
@@ -109,15 +107,23 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
for (TriggerPoint tp : triggers) { for (TriggerPoint tp : triggers) {
String targetMethod = tp.getClassName() + "." + tp.getMethodName(); String targetMethod = tp.getClassName() + "." + tp.getMethodName();
List<List<String>> allPaths = new ArrayList<>(); List<List<String>> allPaths = new ArrayList<>();
for (String sMethod : startMethods) { for (MessagingStartContext startContext : startContexts) {
if (initialBindings.isEmpty()) { if (initialBindings.isEmpty()) {
allPaths.addAll(pathFinder.findAllPaths( allPaths.addAll(pathFinder.findAllPaths(
sMethod, targetMethod, callGraph, new HashSet<>(), startContext.startMethod(),
ep.getClassName())); targetMethod,
callGraph,
new HashSet<>(),
startContext.concreteSelfFqn()));
} else { } else {
allPaths.addAll(pathFinder.findAllPaths( allPaths.addAll(pathFinder.findAllPaths(
sMethod, targetMethod, callGraph, new HashSet<>(), startContext.startMethod(),
pathBindingEvaluator, initialBindings, ep.getClassName())); targetMethod,
callGraph,
new HashSet<>(),
pathBindingEvaluator,
initialBindings,
startContext.concreteSelfFqn()));
} }
} }
Set<List<String>> uniquePaths = new LinkedHashSet<>(allPaths); Set<List<String>> uniquePaths = new LinkedHashSet<>(allPaths);
@@ -146,13 +152,67 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
} }
if (!foundAny && log.isDebugEnabled()) { if (!foundAny && log.isDebugEnabled()) {
log.debug("Entry point {} ({}) did not reach any trigger points. Graph nodes available: {}", 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; 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) { protected TriggerPoint resolveTriggerPointParameters(TriggerPoint tp, List<String> path, Map<String, List<CallEdge>> callGraph) {
return resolveTriggerPointParameters(tp, path, callGraph, Map.of()); return resolveTriggerPointParameters(tp, path, callGraph, Map.of());
} }
@@ -1399,6 +1459,14 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
return null; 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 * 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. * 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 receiver = getReceiverString(node.getExpression());
String declaredReceiverType = String declaredReceiverType =
resolveReceiverTypeFqn(node.getExpression(), td); resolveReceiverTypeFqn(node.getExpression(), td);
String injectedReceiverType =
resolveInjectedReceiverTypeFqn(node.getExpression());
if (injectedReceiverType != null) {
declaredReceiverType = injectedReceiverType;
}
for (String calledMethod : calledMethods) { for (String calledMethod : calledMethods) {
String receiverTypeFqn = String receiverTypeFqn =
refineReceiverTypeForTarget(declaredReceiverType, calledMethod); refineReceiverTypeForTarget(declaredReceiverType, calledMethod);

View File

@@ -139,6 +139,23 @@ public class JdtCallGraphEngine extends AbstractCallGraphEngine {
return null; 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) { private Expression unwrapMessageBuilder(Expression expr) {
Expression peeled = ReactiveExpressionSupport.peelFactoryPayloadExpression(expr); Expression peeled = ReactiveExpressionSupport.peelFactoryPayloadExpression(expr);
return peeled != null ? peeled : 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;
}
}

View File

@@ -0,0 +1,29 @@
plugins {
id 'java'
id 'org.springframework.boot' version '3.5.3'
id 'io.spring.dependency-management' version '1.1.7'
}
group = 'click.kamil'
version = '0.0.1-SNAPSHOT'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter'
implementation 'org.springframework.boot:spring-boot-starter-artemis'
implementation 'org.springframework.statemachine:spring-statemachine-starter:3.2.0'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
}
bootJar { enabled = false }
jar { enabled = true }

View File

@@ -0,0 +1,21 @@
package click.kamil.examples.jmsabstract;
import org.springframework.jms.annotation.JmsListener;
/**
* Template-method JMS base: {@code @JmsListener} lives on the abstract type; subclasses
* implement {@link #doPMessage(String)}.
*/
public abstract class AbstractAA {
@JmsListener(destination = "orders.commands")
public void onMessage(String message) {
processMessage(message);
}
protected void processMessage(String message) {
doPMessage(message);
}
protected abstract void doPMessage(String message);
}

View File

@@ -0,0 +1,19 @@
package click.kamil.examples.jmsabstract;
import org.springframework.statemachine.StateMachine;
import org.springframework.stereotype.Component;
@Component
public class DocumentAA extends AbstractAA {
private final StateMachine<OrderStates, OrderEvents> stateMachine;
public DocumentAA(StateMachine<OrderStates, OrderEvents> stateMachine) {
this.stateMachine = stateMachine;
}
@Override
protected void doPMessage(String message) {
stateMachine.sendEvent(OrderEvents.SUBMIT);
}
}

View File

@@ -0,0 +1,13 @@
package click.kamil.examples.jmsabstract;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.jms.annotation.EnableJms;
@SpringBootApplication
@EnableJms
public class JmsAbstractTemplateApplication {
public static void main(String[] args) {
SpringApplication.run(JmsAbstractTemplateApplication.class, args);
}
}

View File

@@ -0,0 +1,19 @@
package click.kamil.examples.jmsabstract;
import org.springframework.statemachine.StateMachine;
import org.springframework.stereotype.Component;
@Component
public class OrderAA extends AbstractAA {
private final StateMachine<OrderStates, OrderEvents> stateMachine;
public OrderAA(StateMachine<OrderStates, OrderEvents> stateMachine) {
this.stateMachine = stateMachine;
}
@Override
protected void doPMessage(String message) {
stateMachine.sendEvent(OrderEvents.PAY);
}
}

View File

@@ -0,0 +1,5 @@
package click.kamil.examples.jmsabstract;
public enum OrderEvents {
PAY, SUBMIT
}

View File

@@ -0,0 +1,28 @@
package click.kamil.examples.jmsabstract;
import org.springframework.context.annotation.Configuration;
import org.springframework.statemachine.config.EnableStateMachine;
import org.springframework.statemachine.config.StateMachineConfigurerAdapter;
import org.springframework.statemachine.config.builders.StateMachineStateConfigurer;
import org.springframework.statemachine.config.builders.StateMachineTransitionConfigurer;
@Configuration
@EnableStateMachine
public class OrderStateMachineConfig extends StateMachineConfigurerAdapter<OrderStates, OrderEvents> {
@Override
public void configure(StateMachineStateConfigurer<OrderStates, OrderEvents> states) throws Exception {
states.withStates()
.initial(OrderStates.NEW)
.state(OrderStates.PAID)
.state(OrderStates.SUBMITTED);
}
@Override
public void configure(StateMachineTransitionConfigurer<OrderStates, OrderEvents> transitions) throws Exception {
transitions
.withExternal().source(OrderStates.NEW).target(OrderStates.PAID).event(OrderEvents.PAY)
.and()
.withExternal().source(OrderStates.NEW).target(OrderStates.SUBMITTED).event(OrderEvents.SUBMIT);
}
}

View File

@@ -0,0 +1,5 @@
package click.kamil.examples.jmsabstract;
public enum OrderStates {
NEW, PAID, SUBMITTED
}