From 0c152b03181cfca23012f63a201f73e169c4431f Mon Sep 17 00:00:00 2001 From: Kamil Patryk Kozakowski Date: Fri, 17 Jul 2026 07:30:35 +0200 Subject: [PATCH] A --- .../enricher/TransitionLinkerEnricher.java | 13 +++++++ .../resolver/MachineEnumCanonicalizer.java | 24 +++++++++++++ .../service/AbstractCallGraphEngine.java | 18 +++++++++- .../service/GenericEventDetector.java | 29 ++++++++++++++-- .../GenericEventDetectorControlFlowTest.java | 34 +++++++++++++++++++ 5 files changed, 114 insertions(+), 4 deletions(-) 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 76fd71d..21a8192 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 @@ -303,6 +303,19 @@ public class TransitionLinkerEnricher implements AnalysisEnricher { stripped = stripped.replaceAll( eventLike + "\\s*\\.\\s*equals\\s*\\(\\s*\"[^\"]+\"\\s*\\)", "true"); + // Switch-case / arrow constraints often use == on the payload param. + stripped = stripped.replaceAll( + eventLike + "\\s*==\\s*\"[^\"]+\"", + "true"); + stripped = stripped.replaceAll( + "\"[^\"]+\"\\s*==\\s*" + eventLike, + "true"); + stripped = stripped.replaceAll( + eventLike + "\\s*!=\\s*\"[^\"]+\"", + "true"); + stripped = stripped.replaceAll( + "\"[^\"]+\"\\s*!=\\s*" + eventLike, + "true"); stripped = stripped.replaceAll("&&\\s*&&+", "&&"); stripped = stripped.replaceAll("^\\s*&&\\s*", ""); stripped = stripped.replaceAll("\\s*&&\\s*$", ""); diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizer.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizer.java index 441f972..79c2ecc 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizer.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizer.java @@ -928,12 +928,36 @@ public final class MachineEnumCanonicalizer { } if (typePart == null && Character.isUpperCase(stripped.charAt(0)) && !stripped.contains(".")) { + // Only invent enumType.CONST when CONST is a real member — otherwise JMS payload + // labels like "PAY" become OrderState.PAY and break transition linking. + if (context != null && !enumHasConstant(enumTypeFqn, stripped, context)) { + return stripped; + } return enumTypeFqn + "." + stripped; } return stripped; } + private static boolean enumHasConstant(String enumTypeFqn, String constant, CodebaseContext context) { + if (enumTypeFqn == null || constant == null || context == null) { + return false; + } + List values = context.getEnumValues(stripGenerics(enumTypeFqn)); + if (values == null || values.isEmpty()) { + return false; + } + for (String value : values) { + if (value == null) { + continue; + } + if (value.equals(constant) || value.endsWith("." + constant)) { + return true; + } + } + return false; + } + private static String stripQuotes(String value) { if (value.length() >= 2 && value.startsWith("\"") && value.endsWith("\"")) { return value.substring(1, value.length() - 1); 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 e615dc4..c1d9776 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 @@ -12,6 +12,7 @@ import click.kamil.springstatemachineexporter.analysis.model.CallEdge; import click.kamil.springstatemachineexporter.analysis.model.EntryPoint; import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import click.kamil.springstatemachineexporter.analysis.resolver.BooleanConstraintEvaluator; import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver; import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer; import lombok.extern.slf4j.Slf4j; @@ -137,15 +138,30 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { foundAny = true; Map pathBindings = resolvePathBindings(path, callGraph, initialBindings); TriggerPoint resolvedTp = resolveTriggerPointParameters(tp, path, callGraph, initialBindings); + Map constraintBindings = + !pathBindings.isEmpty() ? pathBindings : initialBindings; + if (resolvedTp != null + && resolvedTp.getConstraint() != null + && constraintBindings != null + && !constraintBindings.isEmpty() + && !BooleanConstraintEvaluator.isCompatibleWithBindings( + resolvedTp.getConstraint(), constraintBindings)) { + continue; + } if (resolvedTp != null) { resolvedTp = TriggerMachineTypeRebinder.rebind(resolvedTp, path, context); String contextMachineId = pathFinder.extractContextMachineId(path, callGraph); + Map exportedBindings = !pathBindings.isEmpty() + ? pathBindings + : (initialBindings == null || initialBindings.isEmpty() + ? Map.of() + : initialBindings); chains.add(CallChain.builder() .entryPoint(chainEntryPoint) .triggerPoint(resolvedTp) .methodChain(path) .contextMachineId(contextMachineId) - .pathBindings(pathBindings.isEmpty() ? null : Map.copyOf(pathBindings)) + .pathBindings(exportedBindings.isEmpty() ? null : Map.copyOf(exportedBindings)) .build()); } } 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 439d904..47aab06 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,7 +28,10 @@ public class GenericEventDetector { this.typeResolver = new TypeResolver(context); } - /** Parameter names that route to a machine, not SM state enum constants. */ + /** + * 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", @@ -37,7 +40,17 @@ public class GenericEventDetector { "type", "action", "eventString", - "version"); + "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,6 +413,11 @@ public class GenericEventDetector { Expression selector = resolveSwitchSelector(switchCase); if (selector != null && !isRoutingParameter(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) { + current = parent; + continue; + } String resolved = resolveProvableStateLiteral(caseExpr); if (resolved != null) { return resolved; @@ -668,7 +686,12 @@ public class GenericEventDetector { if (stmtObj instanceof SwitchCase switchCase) { if (!switchCase.expressions().isEmpty()) { - return getSimpleNameString((Expression) switchCase.expressions().get(0)); + Expression caseExpr = (Expression) switchCase.expressions().get(0); + // String case labels are routing keys, not SM source states. + if (caseExpr instanceof StringLiteral) { + continue; + } + return getSimpleNameString(caseExpr); } } else if (stmtObj instanceof IfStatement ifStmt) { // Guard clause detection: check if the 'then' block has a return/throw 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 63072be..b3ba931 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 @@ -196,6 +196,40 @@ class GenericEventDetectorControlFlowTest { assertThat(triggers.get(0).getSourceState()).isNull(); } + @Test + void shouldNotTreatJmsMessageSwitchCasesAsSourceState(@TempDir Path tempDir) throws IOException { + String source = """ + package com.example; + public class OrderListener { + private StateMachine sm; + public void onMessage(String message) { + switch (message) { + 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); + + 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("JMS payload case labels must not become sourceState") + .isNull()); + } + @Test void shouldDetectSourceStateFromLiteralSendEventArgument(@TempDir Path tempDir) throws IOException { String source = """