This commit is contained in:
2026-07-17 07:30:35 +02:00
parent 4cb4ba12e0
commit 0c152b0318
5 changed files with 114 additions and 4 deletions

View File

@@ -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*$", "");

View File

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

View File

@@ -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<String, String> pathBindings = resolvePathBindings(path, callGraph, initialBindings);
TriggerPoint resolvedTp = resolveTriggerPointParameters(tp, path, callGraph, initialBindings);
Map<String, String> 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<String, String> 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());
}
}

View File

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

View File

@@ -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<TriggerPoint> 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 = """