From 40bcde26a364f3daf8c1f741f5b1d37bdbac364b Mon Sep 17 00:00:00 2001 From: Kamil Patryk Kozakowski Date: Fri, 17 Jul 2026 07:35:58 +0200 Subject: [PATCH] A --- .../enricher/CallChainPolyNarrower.java | 18 +- .../enricher/TransitionLinkerEnricher.java | 6 +- .../resolver/BooleanConstraintEvaluator.java | 29 +++ .../service/AbstractCallGraphEngine.java | 2 +- .../service/EntryPointBindingExpander.java | 62 +----- .../analysis/service/EventCarrierPolicy.java | 184 ++++++++++++++++++ .../service/ExternalTriggerPolicy.java | 41 +--- .../service/GenericEventDetector.java | 132 ++++++++++--- .../service/EventCarrierPolicyTest.java | 83 ++++++++ .../GenericEventDetectorControlFlowTest.java | 74 +++++++ 10 files changed, 491 insertions(+), 140 deletions(-) create mode 100644 state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/EventCarrierPolicy.java create mode 100644 state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/EventCarrierPolicyTest.java diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/CallChainPolyNarrower.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/CallChainPolyNarrower.java index 997021a..f8be14d 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/CallChainPolyNarrower.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/CallChainPolyNarrower.java @@ -6,6 +6,7 @@ import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver; import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer; import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver; +import click.kamil.springstatemachineexporter.analysis.service.EventCarrierPolicy; import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; import click.kamil.springstatemachineexporter.model.Transition; import org.eclipse.jdt.core.dom.ASTVisitor; @@ -161,14 +162,7 @@ public final class CallChainPolyNarrower { if (value == null || value.isBlank()) { continue; } - boolean eventLikeKey = key != null && ( - key.equalsIgnoreCase("event") - || key.equalsIgnoreCase("eventString") - || key.equalsIgnoreCase("command") - || key.equalsIgnoreCase("commandKey") - || key.equalsIgnoreCase("action") - || key.equalsIgnoreCase("actionKey") - || key.toLowerCase(Locale.ROOT).endsWith("event")); + boolean eventLikeKey = EventCarrierPolicy.isCarrierParamName(key); int weight = eventLikeKey ? STRONG : MEDIUM; addEnumReference(value, configuredConstants, votes, weight); addPathToken(value, configuredConstants, votes, weight); @@ -186,11 +180,10 @@ public final class CallChainPolyNarrower { while (quoted.find()) { String literal = quoted.group(1); String param = quoted.group(2); - if ("event".equals(param) || "eventString".equals(param) || "actionKey".equals(param) - || "action".equals(param)) { + if (EventCarrierPolicy.isCarrierParamName(param)) { addPathToken(literal, configuredConstants, votes, MEDIUM); } - if ("commandKey".equals(param) || literal.contains(".")) { + if (literal.contains(".")) { addPathToken(literal, configuredConstants, votes, MEDIUM); } } @@ -207,8 +200,7 @@ public final class CallChainPolyNarrower { while (reverseEquals.find()) { String param = reverseEquals.group(1); String literal = reverseEquals.group(2); - if ("event".equals(param) || "eventString".equals(param) || "actionKey".equals(param) - || "action".equals(param) || "commandKey".equals(param)) { + if (EventCarrierPolicy.isCarrierParamName(param)) { addPathToken(literal, configuredConstants, votes, MEDIUM); } } 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 21a8192..6fcb1f0 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 @@ -9,7 +9,9 @@ import click.kamil.springstatemachineexporter.analysis.model.LinkResolution; import click.kamil.springstatemachineexporter.analysis.model.MatchedTransition; import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; import click.kamil.springstatemachineexporter.analysis.service.CodebaseIntelligenceProvider; +import click.kamil.springstatemachineexporter.analysis.service.EventCarrierPolicy; import click.kamil.springstatemachineexporter.analysis.service.JsonExportContextFactory; +import click.kamil.springstatemachineexporter.analysis.service.TriggerMachineTypeRebinder; import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; import click.kamil.springstatemachineexporter.model.State; import click.kamil.springstatemachineexporter.model.Transition; @@ -29,7 +31,6 @@ import click.kamil.springstatemachineexporter.analysis.enricher.routing.SharedSe import click.kamil.springstatemachineexporter.analysis.resolver.BooleanConstraintEvaluator; import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer; import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver; -import click.kamil.springstatemachineexporter.analysis.service.TriggerMachineTypeRebinder; public class TransitionLinkerEnricher implements AnalysisEnricher { @@ -289,8 +290,7 @@ public class TransitionLinkerEnricher implements AnalysisEnricher { } // 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 eventLike = "(?i)(" + EventCarrierPolicy.carrierNameAlternation() + ")"; String stripped = constraint.replaceAll( "\"[^\"]+\"\\s*\\.\\s*equalsIgnoreCase\\s*\\(\\s*" + eventLike + "\\s*\\)", "true"); diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/BooleanConstraintEvaluator.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/BooleanConstraintEvaluator.java index 28ac1b6..b5205fa 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/BooleanConstraintEvaluator.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/BooleanConstraintEvaluator.java @@ -75,6 +75,15 @@ public final class BooleanConstraintEvaluator { return isCompatibleWithBindings(constraint, bindings, null, null); } + /** + * Like {@link #isCompatibleWithBindings(String, Map)} but unbound equality variables are ignored + * instead of failing closed. Used when filtering call chains against entry path/payload bindings + * while trigger constraints may also mention unrelated locals. + */ + public static boolean isCompatibleWithKnownBindings(String constraint, Map bindings) { + return isCompatibleWithKnownBindings(constraint, bindings, null, null); + } + /** * Like {@link #isCompatibleWithBindings(String, Map)} but reconstructs truncated enum refs * using {@code preferredEnumTypeFqn} before treating them as concrete. @@ -84,6 +93,23 @@ public final class BooleanConstraintEvaluator { Map bindings, String preferredEnumTypeFqn, CodebaseContext context) { + return isCompatibleWithBindings(constraint, bindings, preferredEnumTypeFqn, context, false); + } + + public static boolean isCompatibleWithKnownBindings( + String constraint, + Map bindings, + String preferredEnumTypeFqn, + CodebaseContext context) { + return isCompatibleWithBindings(constraint, bindings, preferredEnumTypeFqn, context, true); + } + + private static boolean isCompatibleWithBindings( + String constraint, + Map bindings, + String preferredEnumTypeFqn, + CodebaseContext context, + boolean ignoreUnboundEqualityVars) { if (constraint == null || constraint.isBlank()) { return true; } @@ -93,6 +119,9 @@ public final class BooleanConstraintEvaluator { Set equalityVars = extractEqualityVariables(constraint); for (String varName : equalityVars) { if (!bindings.containsKey(varName)) { + if (ignoreUnboundEqualityVars) { + continue; + } 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 c1d9776..ffa7491 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 @@ -144,7 +144,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { && resolvedTp.getConstraint() != null && constraintBindings != null && !constraintBindings.isEmpty() - && !BooleanConstraintEvaluator.isCompatibleWithBindings( + && !BooleanConstraintEvaluator.isCompatibleWithKnownBindings( resolvedTp.getConstraint(), constraintBindings)) { continue; } 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 a1fd9bd..b8f08b9 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 @@ -62,7 +62,7 @@ public final class EntryPointBindingExpander { continue; } if (!isRequestParam(parameter) - && !isMessagingPayloadParameter(entryPoint, parameter) + && !EventCarrierPolicy.isMessagingPayloadParameter(entryPoint, parameter) && isPlaceholderResolvedInPath(entryPoint, parameter.getName())) { continue; } @@ -284,7 +284,8 @@ public final class EntryPointBindingExpander { return false; } for (EntryPoint.Parameter parameter : entryPoint.getParameters()) { - if (paramName.equals(parameter.getName()) && isMessagingPayloadParameter(entryPoint, parameter)) { + if (paramName.equals(parameter.getName()) + && EventCarrierPolicy.isMessagingPayloadParameter(entryPoint, parameter)) { return true; } } @@ -292,7 +293,8 @@ public final class EntryPointBindingExpander { } private static boolean isExpandableBindingParameter(EntryPoint entryPoint, EntryPoint.Parameter parameter) { - return isPathOrQueryVariable(parameter) || isMessagingPayloadParameter(entryPoint, parameter); + return isPathOrQueryVariable(parameter) + || EventCarrierPolicy.isMessagingPayloadParameter(entryPoint, parameter); } private static boolean isPathOrQueryVariable(EntryPoint.Parameter parameter) { @@ -303,60 +305,6 @@ public final class EntryPointBindingExpander { 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; - } - 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; diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/EventCarrierPolicy.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/EventCarrierPolicy.java new file mode 100644 index 0000000..ff4be91 --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/EventCarrierPolicy.java @@ -0,0 +1,184 @@ +package click.kamil.springstatemachineexporter.analysis.service; + +import click.kamil.springstatemachineexporter.analysis.model.EntryPoint; + +import java.util.Locale; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * Shared identity for parameters that carry event / payload / machine-route discriminators + * rather than SM source-state enum values. Used by binding expansion, trigger detection, + * linking, and poly narrowing so name lists are not duplicated. + */ +public final class EventCarrierPolicy { + + private static final Set CARRIER_NAMES = Set.of( + "message", + "msg", + "payload", + "body", + "command", + "commandkey", + "event", + "e", + "eventstring", + "action", + "actionkey", + "text", + "transition"); + + private static final Set MACHINE_DISCRIMINATOR_NAMES = Set.of( + "machinetype", + "machineid", + "machinename", + "domain", + "type", + "version"); + + private static final Set STATE_ACCESSOR_NAMES = Set.of( + "state", + "currentstate", + "sourcestate", + "fromstate"); + + private EventCarrierPolicy() { + } + + /** Event / payload / command body parameter names (not machine domain keys). */ + public static boolean isCarrierParamName(String name) { + if (name == null || name.isBlank()) { + return false; + } + String lower = name.toLowerCase(Locale.ROOT); + if (CARRIER_NAMES.contains(lower)) { + return true; + } + return lower.endsWith("event"); + } + + /** Machine-type / domain discriminators used for multi-machine routing. */ + public static boolean isMachineDiscriminatorName(String name) { + if (name == null || name.isBlank()) { + return false; + } + return MACHINE_DISCRIMINATOR_NAMES.contains(name.toLowerCase(Locale.ROOT)); + } + + /** Carrier or machine discriminant — must not be treated as SM source state. */ + public static boolean isRoutingDiscriminantName(String name) { + return isCarrierParamName(name) || isMachineDiscriminatorName(name); + } + + /** Clear state-variable / accessor names used for positive source-state inference. */ + public static boolean isStateAccessorName(String name) { + if (name == null || name.isBlank()) { + return false; + } + String lower = name.toLowerCase(Locale.ROOT); + if (STATE_ACCESSOR_NAMES.contains(lower)) { + return true; + } + return lower.endsWith("state") && !isCarrierParamName(name); + } + + /** + * Messaging {@code @Payload} or unannotated String body that looks like an event carrier. + */ + public static boolean isMessagingPayloadParameter(EntryPoint entryPoint, EntryPoint.Parameter parameter) { + if (entryPoint == null || parameter == null || !isMessagingEntryPoint(entryPoint)) { + return false; + } + if (hasAnnotation(parameter, "Payload")) { + return true; + } + boolean unannotated = parameter.getAnnotations() == null || parameter.getAnnotations().isEmpty(); + if (!unannotated) { + return false; + } + if (!isStringType(parameter.getType())) { + return false; + } + return isCarrierParamName(parameter.getName()); + } + + /** + * REST path/query params that carry the event key (not resource ids). + */ + public static boolean isRestEventCarrierParameter(EntryPoint.Parameter parameter) { + if (parameter == null || parameter.getAnnotations() == null) { + return false; + } + boolean pathOrQuery = hasAnnotation(parameter, "PathVariable") + || hasAnnotation(parameter, "RequestParam"); + if (!pathOrQuery) { + return false; + } + return isCarrierParamName(parameter.getName()) || isMachineDiscriminatorName(parameter.getName()); + } + + /** + * Entry-point parameter that expands / binds as an event or payload carrier. + */ + public static boolean isCarrierParameter(EntryPoint entryPoint, EntryPoint.Parameter parameter) { + if (parameter == null) { + return false; + } + if (hasAnnotation(parameter, "Payload")) { + return true; + } + if (isMessagingPayloadParameter(entryPoint, parameter)) { + return true; + } + if (isRestEventCarrierParameter(parameter)) { + return true; + } + return false; + } + + public 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; + }; + } + + /** + * Alternation for regex consumers (linker constraint stripping). Names are already lowercased + * where case-insensitive flags are used by the caller. + */ + public static String carrierNameAlternation() { + return Stream.concat( + CARRIER_NAMES.stream(), + Stream.of("commandKey")) + .distinct() + .sorted() + .collect(Collectors.joining("|")); + } + + public static String routingDiscriminantAlternation() { + return Stream.concat(CARRIER_NAMES.stream(), MACHINE_DISCRIMINATOR_NAMES.stream()) + .sorted() + .collect(Collectors.joining("|")); + } + + private static boolean hasAnnotation(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 isStringType(String type) { + if (type == null) { + return false; + } + String simple = type.contains(".") ? type.substring(type.lastIndexOf('.') + 1) : type; + return "String".equals(simple); + } +} 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 7e4d90b..86a085d 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 @@ -80,7 +80,7 @@ public final class ExternalTriggerPolicy { return true; } } - if (entryPoint != null && isMessagingEntryPoint(entryPoint)) { + if (entryPoint != null && EventCarrierPolicy.isMessagingEntryPoint(entryPoint)) { String paramName = resolveMessagingEventParameterName(entryPoint, resolvedEventParamName); if (paramName != null) { RestParamBinding binding = findRestParameterBinding(entryPoint, paramName, context); @@ -125,16 +125,6 @@ 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; @@ -160,13 +150,7 @@ public final class ExternalTriggerPolicy { } 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"))) { + && EventCarrierPolicy.isCarrierParamName(parameter.getName())) { return parameter.getName(); } } @@ -204,7 +188,8 @@ public final class ExternalTriggerPolicy { return false; } String placeholder = pathOrName.substring(open + 1, close).trim(); - if (isEventLikeParameterName(placeholderName(placeholder))) { + if (EventCarrierPolicy.isCarrierParamName(placeholderName(placeholder)) + || EventCarrierPolicy.isMachineDiscriminatorName(placeholderName(placeholder))) { return true; } start = close + 1; @@ -240,7 +225,8 @@ public final class ExternalTriggerPolicy { if (!pathVariable && !requestParam) { continue; } - if (isEventLikeParameterName(parameter.getName())) { + if (EventCarrierPolicy.isCarrierParamName(parameter.getName()) + || EventCarrierPolicy.isMachineDiscriminatorName(parameter.getName())) { return parameter.getName(); } } @@ -259,21 +245,6 @@ public final class ExternalTriggerPolicy { && value.chars().allMatch(Character::isJavaIdentifierPart); } - private static boolean isEventLikeParameterName(String name) { - if (name == null || name.isBlank()) { - return false; - } - String lower = name.toLowerCase(); - return lower.equals("event") - || lower.equals("e") - || lower.endsWith("event") - || lower.equals("command") - || lower.equals("commandkey") - || lower.equals("transition") - || lower.equals("action") - || lower.equals("machinetype"); - } - private static RestParamBinding findRestParameterBinding( EntryPoint entryPoint, String paramName, CodebaseContext context) { if (entryPoint.getParameters() == null || paramName == null) { diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/GenericEventDetector.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/GenericEventDetector.java index 47aab06..91cd431 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/GenericEventDetector.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/GenericEventDetector.java @@ -28,30 +28,6 @@ public class GenericEventDetector { this.typeResolver = new TypeResolver(context); } - /** - * Parameter names that select a route/event payload, not SM source-state enum constants. - * Includes messaging body names so {@code switch (message)} case labels are not treated as states. - */ - private static final Set ROUTING_PARAMETER_NAMES = Set.of( - "machineType", - "machineId", - "machineName", - "domain", - "type", - "action", - "eventString", - "version", - // JMS / Kafka / Rabbit payload carriers (parity with EntryPointBindingExpander) - "message", - "msg", - "payload", - "body", - "command", - "commandKey", - "commandkey", - "text", - "event"); - private static final Set TRIGGER_METHOD_NAMES = Set.of( "sendEvent", "sendEvents", "sendEventCollect", "sendEventMono", "fire", "trigger"); @@ -400,18 +376,20 @@ public class GenericEventDetector { String state = extractStateFromSiblings(current, block.statements()); if (state != null) return state; } else if (parent instanceof SwitchStatement switchStmt) { - if (!isRoutingParameter(switchStmt.getExpression())) { + if (isSourceStateSelector(switchStmt.getExpression())) { String state = extractStateFromSiblings(current, switchStmt.statements()); if (state != null) return state; } } else if (parent instanceof SwitchExpression switchExpr) { - if (!isRoutingParameter(switchExpr.getExpression())) { + if (isSourceStateSelector(switchExpr.getExpression())) { String state = extractStateFromSiblings(current, switchExpr.statements()); if (state != null) return state; } } else if (parent instanceof SwitchCase switchCase && !switchCase.isDefault()) { Expression selector = resolveSwitchSelector(switchCase); - if (selector != null && !isRoutingParameter(selector) && !switchCase.expressions().isEmpty()) { + if (selector != null + && isSourceStateSelector(selector) + && !switchCase.expressions().isEmpty()) { Expression caseExpr = (Expression) switchCase.expressions().get(0); // String case labels are routing keys (JMS payload / command), not state enums. if (caseExpr instanceof StringLiteral) { @@ -731,7 +709,7 @@ public class GenericEventDetector { return null; } - if (isRoutingParameter(left) || isRoutingParameter(right)) { + if (isRoutingDiscriminant(left) || isRoutingDiscriminant(right)) { return null; } @@ -750,7 +728,7 @@ public class GenericEventDetector { Expression receiver = mi.getExpression(); Expression arg = (Expression) mi.arguments().get(0); - if (isRoutingParameter(receiver) || isRoutingParameter(arg)) { + if (isRoutingDiscriminant(receiver) || isRoutingDiscriminant(arg)) { return null; } @@ -764,13 +742,105 @@ public class GenericEventDetector { return null; } - private boolean isRoutingParameter(Expression expr) { + /** + * Positive evidence that a switch/if selector is an SM source state (not a payload/route key). + */ + private boolean isSourceStateSelector(Expression expr) { + if (expr == null || isRoutingDiscriminant(expr)) { + return false; + } if (expr instanceof SimpleName sn) { - return ROUTING_PARAMETER_NAMES.contains(sn.getIdentifier()); + if (EventCarrierPolicy.isStateAccessorName(sn.getIdentifier())) { + return true; + } + if (isCarrierStringParameter(sn)) { + return false; + } + return isLikelyStateEnumType(sn); + } + if (expr instanceof MethodInvocation mi) { + String name = mi.getName().getIdentifier(); + if ("getState".equals(name) || "getId".equals(name) || EventCarrierPolicy.isStateAccessorName(name)) { + return true; + } + } + return isLikelyStateEnumType(expr); + } + + private boolean isRoutingDiscriminant(Expression expr) { + if (expr instanceof SimpleName sn) { + if (EventCarrierPolicy.isRoutingDiscriminantName(sn.getIdentifier())) { + return true; + } + return isCarrierStringParameter(sn); } return false; } + /** + * {@code String} method parameter that carries a payload/event key (by name, {@code @Payload}, + * or messaging body convention) — never a source-state selector. + */ + private boolean isCarrierStringParameter(SimpleName name) { + MethodDeclaration method = click.kamil.springstatemachineexporter.ast.common.AstUtils + .findEnclosingMethod(name); + if (method == null) { + return false; + } + for (Object paramObj : method.parameters()) { + if (!(paramObj instanceof SingleVariableDeclaration param)) { + continue; + } + if (!param.getName().getIdentifier().equals(name.getIdentifier())) { + continue; + } + if (!isStringType(param.getType())) { + return false; + } + if (hasPayloadAnnotation(param)) { + return true; + } + return EventCarrierPolicy.isCarrierParamName(param.getName().getIdentifier()); + } + return false; + } + + private static boolean isStringType(org.eclipse.jdt.core.dom.Type type) { + if (type == null) { + return false; + } + String raw = type.toString(); + String simple = raw.contains(".") ? raw.substring(raw.lastIndexOf('.') + 1) : raw; + return "String".equals(simple); + } + + private static boolean hasPayloadAnnotation(SingleVariableDeclaration param) { + for (Object modifier : param.modifiers()) { + if (modifier instanceof Annotation annotation) { + String typeName = annotation.getTypeName().getFullyQualifiedName(); + if ("Payload".equals(typeName) || typeName.endsWith(".Payload")) { + return true; + } + } + } + return false; + } + + private boolean isLikelyStateEnumType(Expression expr) { + if (expr == null) { + return false; + } + ITypeBinding binding = expr.resolveTypeBinding(); + if (binding == null) { + return false; + } + if (!binding.isEnum()) { + return false; + } + String simple = binding.getName(); + return simple != null && simple.toLowerCase(java.util.Locale.ROOT).contains("state"); + } + private String getSimpleNameString(Expression expr) { if (expr instanceof QualifiedName qn) { return qn.getName().getIdentifier(); diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/EventCarrierPolicyTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/EventCarrierPolicyTest.java new file mode 100644 index 0000000..ba94c8e --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/EventCarrierPolicyTest.java @@ -0,0 +1,83 @@ +package click.kamil.springstatemachineexporter.analysis.service; + +import click.kamil.springstatemachineexporter.analysis.model.EntryPoint; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class EventCarrierPolicyTest { + + @Test + void carrierNamesIncludeMessagingAndEventConventions() { + assertThat(EventCarrierPolicy.isCarrierParamName("message")).isTrue(); + assertThat(EventCarrierPolicy.isCarrierParamName("payload")).isTrue(); + assertThat(EventCarrierPolicy.isCarrierParamName("commandKey")).isTrue(); + assertThat(EventCarrierPolicy.isCarrierParamName("orderEvent")).isTrue(); + assertThat(EventCarrierPolicy.isCarrierParamName("orderId")).isFalse(); + assertThat(EventCarrierPolicy.isCarrierParamName("cmd")).isFalse(); + } + + @Test + void machineDiscriminatorsAreRoutingButNotCarriers() { + assertThat(EventCarrierPolicy.isMachineDiscriminatorName("machineType")).isTrue(); + assertThat(EventCarrierPolicy.isMachineDiscriminatorName("domain")).isTrue(); + assertThat(EventCarrierPolicy.isCarrierParamName("machineType")).isFalse(); + assertThat(EventCarrierPolicy.isRoutingDiscriminantName("machineType")).isTrue(); + assertThat(EventCarrierPolicy.isRoutingDiscriminantName("message")).isTrue(); + } + + @Test + void stateAccessorNamesArePositiveEvidence() { + assertThat(EventCarrierPolicy.isStateAccessorName("state")).isTrue(); + assertThat(EventCarrierPolicy.isStateAccessorName("currentState")).isTrue(); + assertThat(EventCarrierPolicy.isStateAccessorName("message")).isFalse(); + } + + @Test + void payloadAnnotationMakesMessagingCarrierRegardlessOfName() { + EntryPoint entryPoint = EntryPoint.builder() + .type(EntryPoint.Type.JMS) + .name("JMS: q") + .className("com.example.L") + .methodName("onMessage") + .parameters(List.of(EntryPoint.Parameter.builder() + .name("cmd") + .type("String") + .annotations(List.of("Payload")) + .build())) + .build(); + + assertThat(EventCarrierPolicy.isMessagingPayloadParameter( + entryPoint, entryPoint.getParameters().get(0))).isTrue(); + assertThat(EventCarrierPolicy.isCarrierParameter( + entryPoint, entryPoint.getParameters().get(0))).isTrue(); + } + + @Test + void unannotatedMessagingStringUsesCarrierNameConvention() { + EntryPoint entryPoint = EntryPoint.builder() + .type(EntryPoint.Type.JMS) + .name("JMS: q") + .className("com.example.L") + .methodName("onMessage") + .parameters(List.of(EntryPoint.Parameter.builder() + .name("message") + .type("String") + .annotations(List.of()) + .build())) + .build(); + + assertThat(EventCarrierPolicy.isMessagingPayloadParameter( + entryPoint, entryPoint.getParameters().get(0))).isTrue(); + } + + @Test + void carrierNameAlternationIsNonEmptyForLinkerRegex() { + assertThat(EventCarrierPolicy.carrierNameAlternation()) + .contains("message") + .contains("payload") + .contains("event"); + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/GenericEventDetectorControlFlowTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/GenericEventDetectorControlFlowTest.java index b3ba931..7e1d267 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/GenericEventDetectorControlFlowTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/GenericEventDetectorControlFlowTest.java @@ -230,6 +230,80 @@ class GenericEventDetectorControlFlowTest { .isNull()); } + @Test + void shouldNotTreatPayloadAnnotatedCmdSwitchAsSourceState(@TempDir Path tempDir) throws IOException { + String source = """ + package com.example; + import org.springframework.messaging.handler.annotation.Payload; + public class OrderListener { + private StateMachine sm; + public void onMessage(@Payload String cmd) { + switch (cmd) { + case "PAY" -> sm.sendEvent(OrderEvent.PAY); + case "SHIP" -> sm.sendEvent(OrderEvent.SHIP); + } + } + } + class StateMachine { + public void sendEvent(OrderEvent e) {} + } + enum OrderState { NEW, PAID, SHIPPED } + enum OrderEvent { PAY, SHIP } + """; + Files.writeString(tempDir.resolve("OrderListener.java"), source); + Files.writeString(tempDir.resolve("Payload.java"), """ + package org.springframework.messaging.handler.annotation; + import java.lang.annotation.Retention; + import java.lang.annotation.RetentionPolicy; + @Retention(RetentionPolicy.RUNTIME) + public @interface Payload {} + """); + + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + + GenericEventDetector detector = new GenericEventDetector(context, new ConstantResolver(), Collections.emptyList()); + List triggers = detector.detect( + (org.eclipse.jdt.core.dom.CompilationUnit) context.getTypeDeclaration("com.example.OrderListener").getRoot()); + + assertThat(triggers).hasSize(2); + assertThat(triggers).allSatisfy(t -> assertThat(t.getSourceState()) + .as("@Payload String cmd switch must not invent sourceState") + .isNull()); + } + + @Test + void shouldNotTreatNonCarrierStringSwitchAsSourceState(@TempDir Path tempDir) throws IOException { + String source = """ + package com.example; + public class OrderListener { + private StateMachine sm; + public void onMessage(String cmd) { + switch (cmd) { + case "PAY" -> sm.sendEvent(OrderEvent.PAY); + } + } + } + class StateMachine { + public void sendEvent(OrderEvent e) {} + } + enum OrderEvent { PAY } + """; + Files.writeString(tempDir.resolve("OrderListener.java"), source); + + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + + GenericEventDetector detector = new GenericEventDetector(context, new ConstantResolver(), Collections.emptyList()); + List triggers = detector.detect( + (org.eclipse.jdt.core.dom.CompilationUnit) context.getTypeDeclaration("com.example.OrderListener").getRoot()); + + assertThat(triggers).hasSize(1); + assertThat(triggers.get(0).getSourceState()) + .as("String switch selector that is not a state enum must not become sourceState") + .isNull(); + } + @Test void shouldDetectSourceStateFromLiteralSendEventArgument(@TempDir Path tempDir) throws IOException { String source = """