This commit is contained in:
2026-07-16 20:10:41 +02:00
parent 4b5de0e167
commit 139eee0a43
6 changed files with 434 additions and 26 deletions

View File

@@ -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<String, String> bindings = chain.getPathBindings();
return bindings == null || bindings.isEmpty();
}
private static boolean isUnresolvedGenericRestEndpoint(EntryPoint entryPoint) {
if (entryPoint == null) {
return false;

View File

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

View File

@@ -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<Map<String, String>> expandEntryPointBindings(
@@ -57,10 +58,12 @@ public final class EntryPointBindingExpander {
List<Map<String, String>> 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<Map<String, String>> 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) {

View File

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

View File

@@ -123,7 +123,12 @@ public class MessagingDetector {
List<String> 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()

View File

@@ -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<String, List<click.kamil.springstatemachineexporter.analysis.model.CallEdge>> graph =
engine.buildCallGraph();
EntryPoint entryPoint = jmsEntryPoint();
List<Map<String, String>> 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<CallChain> 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<CallChain> 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<CallChain> 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<EntryPoint> 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;
}
}