diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/TransitionLinkerEnricher.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/TransitionLinkerEnricher.java index 4d5c10e..063c12e 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/TransitionLinkerEnricher.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/TransitionLinkerEnricher.java @@ -88,10 +88,10 @@ public class TransitionLinkerEnricher implements AnalysisEnricher { continue; } - // Only unbound *event* path templates (e.g. /{event}) stay unresolved-external. + // Unbound event templates (REST /{event}) and unbound messaging payloads stay unresolved. // Resource placeholders like /{id}/pay must still link when the event is known. if (tp.isExternal() - && isUnresolvedGenericRestEndpoint(chain.getEntryPoint()) + && isUnresolvedExternalEndpoint(chain, tp) && !hasConcreteEventEvidence(tp)) { updatedChains.add(chain.toBuilder() .triggerPoint(tp) @@ -103,7 +103,7 @@ public class TransitionLinkerEnricher implements AnalysisEnricher { // Clear spurious external flags (unrelated PathVariable/RequestParam) once we can link. if (tp.isExternal() - && (!isUnresolvedGenericRestEndpoint(chain.getEntryPoint()) || hasConcreteEventEvidence(tp))) { + && (!isUnresolvedExternalEndpoint(chain, tp) || hasConcreteEventEvidence(tp))) { tp = tp.toBuilder().external(false).build(); } @@ -288,7 +288,22 @@ public class TransitionLinkerEnricher implements AnalysisEnricher { if (constraint == null || constraint.isBlank()) { return constraint; } - String stripped = constraint.replaceAll("\"[^\"]+\"\\.equalsIgnoreCase\\(event\\)", "true"); + // Event/payload binding clauses prove which event was selected — not which machine. + // Keep machineType/domain comparisons for isCompatibleWithMachineDomain. + String eventLike = + "(?i)(event|e|message|msg|payload|body|command|commandKey|commandkey|action|text)"; + String stripped = constraint.replaceAll( + "\"[^\"]+\"\\s*\\.\\s*equalsIgnoreCase\\s*\\(\\s*" + eventLike + "\\s*\\)", + "true"); + stripped = stripped.replaceAll( + eventLike + "\\s*\\.\\s*equalsIgnoreCase\\s*\\(\\s*\"[^\"]+\"\\s*\\)", + "true"); + stripped = stripped.replaceAll( + "\"[^\"]+\"\\s*\\.\\s*equals\\s*\\(\\s*" + eventLike + "\\s*\\)", + "true"); + stripped = stripped.replaceAll( + eventLike + "\\s*\\.\\s*equals\\s*\\(\\s*\"[^\"]+\"\\s*\\)", + "true"); stripped = stripped.replaceAll("&&\\s*&&+", "&&"); stripped = stripped.replaceAll("^\\s*&&\\s*", ""); stripped = stripped.replaceAll("\\s*&&\\s*$", ""); @@ -347,6 +362,36 @@ public class TransitionLinkerEnricher implements AnalysisEnricher { * True when the entry path still has an unbound event-carrying placeholder * ({@code {event}}, {@code {commandKey}}, …). Resource ids like {@code {id}} do not count. */ + private static boolean isUnresolvedExternalEndpoint(CallChain chain, TriggerPoint trigger) { + if (chain == null) { + return false; + } + if (isUnresolvedGenericRestEndpoint(chain.getEntryPoint())) { + return true; + } + return isUnresolvedMessagingPayloadEndpoint(chain, trigger); + } + + private static boolean isUnresolvedMessagingPayloadEndpoint(CallChain chain, TriggerPoint trigger) { + EntryPoint entryPoint = chain.getEntryPoint(); + if (entryPoint == null || entryPoint.getType() == null || trigger == null) { + return false; + } + boolean messaging = switch (entryPoint.getType()) { + case JMS, KAFKA, RABBIT, SQS, SNS -> true; + default -> false; + }; + if (!messaging) { + return false; + } + if (!MachineEnumCanonicalizer.isDynamicTriggerExpression(trigger.getEvent())) { + return false; + } + // Expander variants carry concrete payload bindings (same channel as REST pathBindings). + Map bindings = chain.getPathBindings(); + return bindings == null || bindings.isEmpty(); + } + private static boolean isUnresolvedGenericRestEndpoint(EntryPoint entryPoint) { if (entryPoint == null) { return false; 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 72ae9b7..a4bf9af 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 @@ -2262,7 +2262,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { || typeName.endsWith("PathParam") || typeName.endsWith("QueryParam")) { return true; } - if (typeName.endsWith("RequestBody")) { + if (typeName.endsWith("RequestBody") || typeName.endsWith("Payload")) { ITypeBinding typeBinding = svd.getType().resolveBinding(); if (typeBinding != null && typeBinding.isEnum()) { return false; diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/EntryPointBindingExpander.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/EntryPointBindingExpander.java index 4b3dbb8..a1fd9bd 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/EntryPointBindingExpander.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/EntryPointBindingExpander.java @@ -12,8 +12,9 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; /** - * Expands REST entry points whose {@code @PathVariable} parameters flow into string-keyed - * switch mappers (e.g. {@code StringCommandMapper.fromString}) into concrete binding variants. + * Expands entry points whose string parameters flow into switch/map/`valueOf` mappers + * into concrete binding variants. Covers REST {@code @PathVariable}/{@code @RequestParam} + * and messaging {@code @Payload} / message-body string parameters (JMS, Kafka, Rabbit, …). */ public final class EntryPointBindingExpander { @@ -21,7 +22,7 @@ public final class EntryPointBindingExpander { } /** - * Expands an entry point into concrete binding variants from path/query parameters: + * Expands an entry point into concrete binding variants from expandable parameters: * string switch/if arms and boolean ternary conditions provable in source. */ public static List> expandEntryPointBindings( @@ -57,10 +58,12 @@ public final class EntryPointBindingExpander { List> variants = new ArrayList<>(); variants.add(new LinkedHashMap<>()); for (EntryPoint.Parameter parameter : entryPoint.getParameters()) { - if (!isPathOrQueryVariable(parameter)) { + if (!isExpandableBindingParameter(entryPoint, parameter)) { continue; } - if (!isRequestParam(parameter) && isPlaceholderResolvedInPath(entryPoint, parameter.getName())) { + if (!isRequestParam(parameter) + && !isMessagingPayloadParameter(entryPoint, parameter) + && isPlaceholderResolvedInPath(entryPoint, parameter.getName())) { continue; } List> expanded = new ArrayList<>(); @@ -120,6 +123,7 @@ public final class EntryPointBindingExpander { metadata.put("path", path.replace(placeholder, binding.getValue())); } } else if (isRequestParamParameter(entryPoint, binding.getKey()) + || isMessagingPayloadBinding(entryPoint, binding.getKey()) || isBooleanBindingValue(binding.getValue())) { if (querySuffix.isEmpty()) { querySuffix.append('?'); @@ -275,19 +279,90 @@ public final class EntryPointBindingExpander { return false; } - private static boolean isPathOrQueryVariable(EntryPoint.Parameter parameter) { - if (parameter.getAnnotations() == null) { + private static boolean isMessagingPayloadBinding(EntryPoint entryPoint, String paramName) { + if (entryPoint == null || entryPoint.getParameters() == null || paramName == null) { return false; } - return parameter.getAnnotations().stream() - .anyMatch(a -> "PathVariable".equals(a) || "RequestParam".equals(a)); + for (EntryPoint.Parameter parameter : entryPoint.getParameters()) { + if (paramName.equals(parameter.getName()) && isMessagingPayloadParameter(entryPoint, parameter)) { + return true; + } + } + return false; + } + + private static boolean isExpandableBindingParameter(EntryPoint entryPoint, EntryPoint.Parameter parameter) { + return isPathOrQueryVariable(parameter) || isMessagingPayloadParameter(entryPoint, parameter); + } + + private static boolean isPathOrQueryVariable(EntryPoint.Parameter parameter) { + return hasSimpleAnnotation(parameter, "PathVariable") || hasSimpleAnnotation(parameter, "RequestParam"); } private static boolean isRequestParam(EntryPoint.Parameter parameter) { - if (parameter.getAnnotations() == null) { + return hasSimpleAnnotation(parameter, "RequestParam"); + } + + /** + * Messaging listeners carry the event key in {@code @Payload} or an unannotated String body + * ({@code message}, {@code payload}, {@code event}, …) — same expansion surface as REST path vars. + */ + private static boolean isMessagingPayloadParameter(EntryPoint entryPoint, EntryPoint.Parameter parameter) { + if (entryPoint == null || parameter == null || !isMessagingEntryPoint(entryPoint)) { return false; } - return parameter.getAnnotations().stream().anyMatch("RequestParam"::equals); + if (hasSimpleAnnotation(parameter, "Payload")) { + return true; + } + boolean unannotated = parameter.getAnnotations() == null || parameter.getAnnotations().isEmpty(); + if (!unannotated) { + return false; + } + String type = parameter.getType(); + if (type == null) { + return false; + } + String simpleType = type.contains(".") ? type.substring(type.lastIndexOf('.') + 1) : type; + if (!"String".equals(simpleType)) { + return false; + } + return isMessageOrEventParamName(parameter.getName()); + } + + private static boolean isMessagingEntryPoint(EntryPoint entryPoint) { + if (entryPoint.getType() == null) { + return false; + } + return switch (entryPoint.getType()) { + case JMS, KAFKA, RABBIT, SQS, SNS -> true; + default -> false; + }; + } + + private static boolean isMessageOrEventParamName(String name) { + if (name == null || name.isBlank()) { + return false; + } + String lower = name.toLowerCase(); + return lower.equals("message") + || lower.equals("msg") + || lower.equals("payload") + || lower.equals("body") + || lower.equals("event") + || lower.equals("e") + || lower.endsWith("event") + || lower.equals("command") + || lower.equals("commandkey") + || lower.equals("action") + || lower.equals("text"); + } + + private static boolean hasSimpleAnnotation(EntryPoint.Parameter parameter, String simpleName) { + if (parameter.getAnnotations() == null || simpleName == null) { + return false; + } + return parameter.getAnnotations().stream().anyMatch(a -> + simpleName.equals(a) || (a != null && a.endsWith("." + simpleName))); } private static boolean isRequestParamParameter(EntryPoint entryPoint, String paramName) { diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/ExternalTriggerPolicy.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/ExternalTriggerPolicy.java index ec7814a..7e4d90b 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/ExternalTriggerPolicy.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/ExternalTriggerPolicy.java @@ -9,7 +9,8 @@ import java.util.HashSet; import java.util.Set; /** - * Single source-derived policy for marking REST triggers as external vs internal. + * Single source-derived policy for marking entry-point triggers as external vs internal + * (REST path/query/body and messaging payload parameters). */ public final class ExternalTriggerPolicy { @@ -79,13 +80,34 @@ public final class ExternalTriggerPolicy { return true; } } + if (entryPoint != null && isMessagingEntryPoint(entryPoint)) { + String paramName = resolveMessagingEventParameterName(entryPoint, resolvedEventParamName); + if (paramName != null) { + RestParamBinding binding = findRestParameterBinding(entryPoint, paramName, context); + if (binding != null && binding.payloadEnum()) { + return false; + } + RestParamBinding astBinding = entryPoint.getClassName() != null && entryPoint.getMethodName() != null + ? findMethodParameterBinding( + entryPoint.getClassName() + "." + entryPoint.getMethodName(), paramName, context) + : null; + if (astBinding != null && astBinding.payloadEnum()) { + return false; + } + } + if (MachineEnumCanonicalizer.isDynamicTriggerExpression(trigger.getEvent()) + && !hasConcreteSinglePoly(trigger)) { + // Unbound messaging payload feeding valueOf/switch — treat like unbound REST event param. + return true; + } + } if (entryMethodFqn != null && resolvedEventParamName != null) { RestParamBinding binding = findMethodParameterBinding(entryMethodFqn, resolvedEventParamName, context); if (binding != null) { if (binding.pathOrQueryVariable() && !binding.enumType()) { return true; } - if (binding.requestBodyEnum()) { + if (binding.requestBodyEnum() || binding.payloadEnum()) { return false; } } @@ -103,6 +125,54 @@ public final class ExternalTriggerPolicy { return trigger.isExternal(); } + private static boolean isMessagingEntryPoint(EntryPoint entryPoint) { + if (entryPoint == null || entryPoint.getType() == null) { + return false; + } + return switch (entryPoint.getType()) { + case JMS, KAFKA, RABBIT, SQS, SNS -> true; + default -> false; + }; + } + + private static boolean hasConcreteSinglePoly(TriggerPoint trigger) { + if (trigger.getPolymorphicEvents() == null || trigger.getPolymorphicEvents().isEmpty()) { + return false; + } + return MachineEnumCanonicalizer.hasOnlyConcretePolymorphicEvents(trigger.getPolymorphicEvents()) + && trigger.getPolymorphicEvents().size() == 1; + } + + private static String resolveMessagingEventParameterName(EntryPoint entryPoint, String resolvedEventParamName) { + if (resolvedEventParamName != null && !resolvedEventParamName.isBlank() + && looksLikeParameterName(resolvedEventParamName)) { + return resolvedEventParamName; + } + if (entryPoint.getParameters() == null) { + return null; + } + for (EntryPoint.Parameter parameter : entryPoint.getParameters()) { + if (parameter.getAnnotations() != null + && parameter.getAnnotations().stream().anyMatch(a -> + "Payload".equals(a) || (a != null && a.endsWith(".Payload")))) { + return parameter.getName(); + } + } + for (EntryPoint.Parameter parameter : entryPoint.getParameters()) { + if ((parameter.getAnnotations() == null || parameter.getAnnotations().isEmpty()) + && isEventLikeParameterName(parameter.getName())) { + return parameter.getName(); + } + String lower = parameter.getName() == null ? "" : parameter.getName().toLowerCase(); + if ((parameter.getAnnotations() == null || parameter.getAnnotations().isEmpty()) + && (lower.equals("message") || lower.equals("msg") || lower.equals("payload") + || lower.equals("body") || lower.equals("text"))) { + return parameter.getName(); + } + } + return null; + } + private static boolean entryPointHasUnboundPlaceholders(EntryPoint entryPoint) { if (entryPoint == null) { return false; @@ -276,13 +346,13 @@ public final class ExternalTriggerPolicy { continue; } if (isEnumRecordComponent(bodyBinding, fieldName)) { - return new RestParamBinding(false, false, true, true); + return new RestParamBinding(false, false, true, false, true); } if (isEnumDtoField(bodyBinding, fieldName, context)) { - return new RestParamBinding(false, false, true, true); + return new RestParamBinding(false, false, true, false, true); } if (containsNestedEnumField(bodyBinding, fieldName, context)) { - return new RestParamBinding(false, false, true, true); + return new RestParamBinding(false, false, true, false, true); } } return null; @@ -371,7 +441,12 @@ public final class ExternalTriggerPolicy { return false; } - private record RestParamBinding(boolean pathVariable, boolean requestParam, boolean requestBody, boolean enumType) { + private record RestParamBinding( + boolean pathVariable, + boolean requestParam, + boolean requestBody, + boolean payload, + boolean enumType) { boolean pathOrQueryVariable() { return pathVariable || requestParam; } @@ -380,25 +455,32 @@ public final class ExternalTriggerPolicy { return requestBody && enumType; } + boolean payloadEnum() { + return payload && enumType; + } + static RestParamBinding fromEntryPointParameter(EntryPoint.Parameter parameter) { boolean pathVariable = hasAnnotation(parameter.getAnnotations(), "PathVariable"); boolean requestParam = hasAnnotation(parameter.getAnnotations(), "RequestParam"); boolean requestBody = hasAnnotation(parameter.getAnnotations(), "RequestBody"); + boolean payload = hasAnnotation(parameter.getAnnotations(), "Payload"); boolean enumType = isEnumTypeName(parameter.getType()); - return new RestParamBinding(pathVariable, requestParam, requestBody, enumType); + return new RestParamBinding(pathVariable, requestParam, requestBody, payload, enumType); } static RestParamBinding fromAstParameter(SingleVariableDeclaration param) { boolean pathVariable = hasParameterAstAnnotation(param, "PathVariable"); boolean requestParam = hasParameterAstAnnotation(param, "RequestParam"); boolean requestBody = hasParameterAstAnnotation(param, "RequestBody"); + boolean payload = hasParameterAstAnnotation(param, "Payload"); boolean enumType = param.getType() != null && param.getType().resolveBinding() != null && param.getType().resolveBinding().isEnum(); - return new RestParamBinding(pathVariable, requestParam, requestBody, enumType); + return new RestParamBinding(pathVariable, requestParam, requestBody, payload, enumType); } private static boolean hasAnnotation(java.util.List annotations, String name) { - return annotations != null && annotations.stream().anyMatch(name::equals); + return annotations != null && annotations.stream().anyMatch(a -> + name.equals(a) || (a != null && a.endsWith("." + name))); } private static boolean isEnumTypeName(String typeName) { diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/MessagingDetector.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/MessagingDetector.java index c571cc7..cc33473 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/MessagingDetector.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/MessagingDetector.java @@ -123,7 +123,12 @@ public class MessagingDetector { List annotations = new ArrayList<>(); for (Object mod : param.modifiers()) { if (mod instanceof Annotation ann) { - annotations.add(ann.getTypeName().getFullyQualifiedName()); + String name = ann.getTypeName().getFullyQualifiedName(); + int lastDot = name.lastIndexOf('.'); + if (lastDot >= 0) { + name = name.substring(lastDot + 1); + } + annotations.add(name); } } parameters.add(EntryPoint.Parameter.builder() diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/JmsPayloadBindingTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/JmsPayloadBindingTest.java new file mode 100644 index 0000000..9d66195 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/JmsPayloadBindingTest.java @@ -0,0 +1,201 @@ +package click.kamil.springstatemachineexporter.analysis.service; + +import click.kamil.springstatemachineexporter.analysis.enricher.TransitionLinkerEnricher; +import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult; +import click.kamil.springstatemachineexporter.analysis.model.CallChain; +import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata; +import click.kamil.springstatemachineexporter.analysis.model.EntryPoint; +import click.kamil.springstatemachineexporter.analysis.model.LinkResolution; +import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import click.kamil.springstatemachineexporter.model.Event; +import click.kamil.springstatemachineexporter.model.State; +import click.kamil.springstatemachineexporter.model.Transition; +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 payload params must enter the same binding-expansion path as REST PathVariables. + */ +class JmsPayloadBindingTest { + + @Test + void shouldExpandJmsMessagePayloadFromStringSwitchMapper(@TempDir Path tempDir) throws Exception { + writeJmsSwitchProject(tempDir); + + CodebaseContext context = scan(tempDir); + HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context); + Map> graph = + engine.buildCallGraph(); + + EntryPoint entryPoint = jmsEntryPoint(); + + List> variants = + EntryPointBindingExpander.expandPathVariableBindings(entryPoint, context, graph); + + assertThat(variants).extracting(map -> map.get("message")) + .containsExactlyInAnyOrder("PAY", "SHIP"); + } + + @Test + void shouldProduceSeparateChainsForJmsPayloadSwitchCases(@TempDir Path tempDir) throws Exception { + writeJmsSwitchProject(tempDir); + + CodebaseContext context = scan(tempDir); + HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context); + + List chains = engine.findChains( + List.of(jmsEntryPoint()), + List.of(TriggerPoint.builder() + .className("com.example.OrderService") + .methodName("fire") + .event("event") + .build())); + + assertThat(chains).hasSize(2); + assertThat(chains.stream().map(chain -> chain.getEntryPoint().getName())) + .containsExactlyInAnyOrder( + "JMS: order.commands?message=PAY", + "JMS: order.commands?message=SHIP"); + } + + @Test + void shouldLinkExpandedJmsPayloadChainsToMatchingTransitions(@TempDir Path tempDir) throws Exception { + writeJmsSwitchProject(tempDir); + + CodebaseContext context = scan(tempDir); + HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context); + + List chains = engine.findChains( + List.of(jmsEntryPoint()), + List.of(TriggerPoint.builder() + .className("com.example.OrderService") + .methodName("fire") + .event("event") + .build())); + + AnalysisResult result = AnalysisResult.builder() + .name("OrderStateMachineConfig") + .eventTypeFqn("com.example.OrderEvent") + .transitions(List.of( + transition("PAY", "NEW", "PAID"), + transition("SHIP", "PAID", "SHIPPED"))) + .metadata(CodebaseMetadata.builder().callChains(chains).build()) + .build(); + + new TransitionLinkerEnricher().enrich(result, context, null); + + List linked = result.getMetadata().getCallChains(); + assertThat(linked).hasSize(2); + assertThat(linked).allSatisfy(chain -> { + assertThat(chain.getLinkResolution()).isEqualTo(LinkResolution.RESOLVED); + assertThat(chain.getMatchedTransitions()).hasSize(1); + }); + assertThat(linked.stream() + .flatMap(chain -> chain.getMatchedTransitions().stream()) + .map(mt -> mt.getEvent()) + .toList()) + .containsExactlyInAnyOrder( + "com.example.OrderEvent.PAY", + "com.example.OrderEvent.SHIP"); + } + + @Test + void shouldDetectJmsListenerEntryPoint(@TempDir Path tempDir) throws Exception { + writeJmsSwitchProject(tempDir); + CodebaseContext context = scan(tempDir); + + MessagingDetector detector = new MessagingDetector(context); + List entryPoints = context.getCompilationUnits().stream() + .flatMap(cu -> detector.detect(cu).stream()) + .toList(); + + assertThat(entryPoints).anySatisfy(ep -> { + assertThat(ep.getType()).isEqualTo(EntryPoint.Type.JMS); + assertThat(ep.getName()).isEqualTo("JMS: order.commands"); + assertThat(ep.getMethodName()).isEqualTo("onMessage"); + }); + } + + private static EntryPoint jmsEntryPoint() { + return EntryPoint.builder() + .type(EntryPoint.Type.JMS) + .name("JMS: order.commands") + .className("com.example.OrderJmsListener") + .methodName("onMessage") + .metadata(Map.of("destination", "order.commands", "protocol", "JMS")) + .parameters(List.of(EntryPoint.Parameter.builder() + .name("message") + .type("String") + .annotations(List.of()) + .build())) + .build(); + } + + private static void writeJmsSwitchProject(Path tempDir) throws Exception { + Path javaRoot = tempDir.resolve("src/main/java/com/example"); + Files.createDirectories(javaRoot); + Files.writeString(tempDir.resolve("build.gradle"), "plugins { id 'java' }"); + Files.writeString(javaRoot.resolve("OrderEvent.java"), """ + package com.example; + public enum OrderEvent { PAY, SHIP, LOG } + """); + Files.writeString(javaRoot.resolve("OrderService.java"), """ + package com.example; + public class OrderService { + void fire(OrderEvent event) {} + void dispatch(String message) { + OrderEvent event = switch (message) { + case "PAY" -> OrderEvent.PAY; + case "SHIP" -> OrderEvent.SHIP; + default -> throw new IllegalArgumentException(message); + }; + fire(event); + } + } + """); + Files.writeString(javaRoot.resolve("OrderJmsListener.java"), """ + package com.example; + import org.springframework.jms.annotation.JmsListener; + public class OrderJmsListener { + private final OrderService orderService = new OrderService(); + @JmsListener(destination = "order.commands") + public void onMessage(String message) { + orderService.dispatch(message); + } + } + """); + Files.writeString(javaRoot.resolve("JmsListener.java"), """ + package org.springframework.jms.annotation; + import java.lang.annotation.Retention; + import java.lang.annotation.RetentionPolicy; + @Retention(RetentionPolicy.RUNTIME) + public @interface JmsListener { + String destination() default ""; + } + """); + } + + private static CodebaseContext scan(Path tempDir) throws Exception { + CodebaseContext context = new CodebaseContext(); + context.setResolveBindings(true); + context.setSourcepath(List.of(tempDir.resolve("src/main/java").toString())); + context.scan(tempDir); + return context; + } + + private static Transition transition(String event, String source, String target) { + Transition transition = new Transition(); + transition.setEvent(Event.of(event, "com.example.OrderEvent." + event)); + transition.setSourceStates(List.of(State.of(source, "com.example.OrderState." + source))); + transition.setTargetStates(List.of(State.of(target, "com.example.OrderState." + target))); + return transition; + } +}