Replace .get() heuristics with type-based access classification and extend literal dataflow resolution.

Introduce ExpressionAccessClassifier to distinguish map lookups from Supplier/bean accessors, fold Map.of and string/array literals at compile time, and add regression tests for dispatcher and dataflow paths.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-11 10:14:23 +02:00
parent 12183693aa
commit c447641800
48 changed files with 4174 additions and 184 deletions

View File

@@ -15,6 +15,7 @@ include ':state_machines:inheritance_extra_functions3_state_machine'
include ':state_machines:simple_state_machine'
include ':state_machines:extended_analysis_sample'
include ':state_machines:inheritance_sample'
include ':state_machines:layered_dispatcher_sample'
include ':state_machines:ultimate_ecosystem_sm'
include ':state_machines:enterprise_order_system'
include ':state_machines:multi_module_sample:api-module'

View File

@@ -19,6 +19,7 @@ import click.kamil.springstatemachineexporter.analysis.enricher.routing.Heuristi
import click.kamil.springstatemachineexporter.analysis.enricher.routing.BeanResolutionEngine;
import click.kamil.springstatemachineexporter.analysis.enricher.matching.EventMatchingEngine;
import click.kamil.springstatemachineexporter.analysis.enricher.matching.StrictFqnMatchingEngine;
import click.kamil.springstatemachineexporter.analysis.resolver.BooleanConstraintEvaluator;
public class TransitionLinkerEnricher implements AnalysisEnricher {
@@ -109,91 +110,30 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
}
private boolean isConstraintCompatible(String constraint, String machineName) {
if (constraint == null || machineName == null) return true;
String cleanMachine = machineName.substring(machineName.lastIndexOf('.') + 1).toUpperCase();
if (cleanMachine.endsWith("STATEMACHINECONFIGURATION")) {
cleanMachine = cleanMachine.substring(0, cleanMachine.length() - "STATEMACHINECONFIGURATION".length());
}
if (cleanMachine.endsWith("STATEMACHINE")) {
cleanMachine = cleanMachine.substring(0, cleanMachine.length() - "STATEMACHINE".length());
}
if (cleanMachine.endsWith("CONFIG")) {
cleanMachine = cleanMachine.substring(0, cleanMachine.length() - "CONFIG".length());
}
String expr = constraint;
java.util.regex.Pattern p = java.util.regex.Pattern.compile("[\"']([a-zA-Z0-9_-]+)[\"']");
java.util.regex.Matcher matcher = p.matcher(constraint);
java.util.Set<String> literals = new java.util.HashSet<>();
while (matcher.find()) {
literals.add(matcher.group(1));
}
for (String m : literals) {
boolean isCurrent = m.equalsIgnoreCase(cleanMachine);
String replacement = isCurrent ? "true" : "false";
String regex1 = "(?i)[\"']?" + m + "[\"']?\\s*\\.\\s*(equalsIgnoreCase|equals)\\s*\\([^)]*\\)";
String regex2 = "(?i)[a-zA-Z0-9._]+\\s*\\.\\s*(equalsIgnoreCase|equals)\\s*\\(\\s*[\"']?" + m + "[\"']?\\s*\\)";
String regex3 = "(?i)[a-zA-Z0-9._]+\\s*==\\s*[\"']?" + m + "[\"']?";
String regex4 = "(?i)[a-zA-Z0-9._]+\\s*!=\\s*[\"']?" + m + "[\"']?";
expr = expr.replaceAll(regex1, replacement);
expr = expr.replaceAll(regex2, replacement);
expr = expr.replaceAll(regex3, replacement);
expr = expr.replaceAll(regex4, isCurrent ? "false" : "true");
}
try {
return BooleanEvaluator.evaluate(expr);
} catch (Exception e) {
return !expr.contains("false");
}
}
private static class BooleanEvaluator {
public static boolean evaluate(String expr) {
expr = expr.replaceAll("\\s+", "");
return parseExpr(expr);
}
private static boolean parseExpr(String s) {
if (s.isEmpty()) return true;
int parenDepth = 0;
for (int i = s.length() - 1; i >= 0; i--) {
char c = s.charAt(i);
if (c == ')') parenDepth++;
else if (c == '(') parenDepth--;
else if (parenDepth == 0 && i > 0 && s.charAt(i) == '|' && s.charAt(i - 1) == '|') {
return parseExpr(s.substring(0, i - 1)) || parseExpr(s.substring(i + 1));
}
}
parenDepth = 0;
for (int i = s.length() - 1; i >= 0; i--) {
char c = s.charAt(i);
if (c == ')') parenDepth++;
else if (c == '(') parenDepth--;
else if (parenDepth == 0 && i > 0 && s.charAt(i) == '&' && s.charAt(i - 1) == '&') {
return parseExpr(s.substring(0, i - 1)) && parseExpr(s.substring(i + 1));
}
}
if (s.startsWith("!")) {
return !parseExpr(s.substring(1));
}
if (s.startsWith("(") && s.endsWith(")")) {
return parseExpr(s.substring(1, s.length() - 1));
}
if (s.equalsIgnoreCase("true")) return true;
if (s.equalsIgnoreCase("false")) return false;
if (constraint == null || machineName == null) {
return true;
}
return BooleanConstraintEvaluator.isCompatibleWithMachineDomain(
constraint, extractMachineDomainKey(machineName));
}
private String extractMachineDomainKey(String machineName) {
String simple = machineName.substring(machineName.lastIndexOf('.') + 1);
String upper = simple.toUpperCase();
for (String marker : List.of("STATEMACHINECONFIGURATION", "STATEMACHINE", "CONFIGURATION", "CONFIG")) {
int idx = upper.indexOf(marker);
if (idx > 0) {
return upper.substring(0, idx);
}
}
int stateIdx = upper.indexOf("STATE");
if (stateIdx > 0) {
return upper.substring(0, stateIdx);
}
return upper;
}
private final java.util.Map<String, String> simplifyCache = new java.util.HashMap<>();

View File

@@ -13,55 +13,45 @@ public class StrictFqnMatchingEngine implements EventMatchingEngine {
}
if (triggerPoint.isExternal()) {
return true;
List<String> polyEvents = triggerPoint.getPolymorphicEvents() != null
? triggerPoint.getPolymorphicEvents()
: java.util.Collections.emptyList();
if (polyEvents.isEmpty()) {
return false;
}
}
String smEventRaw = stateMachineEvent.fullIdentifier() != null ? stateMachineEvent.fullIdentifier() : stateMachineEvent.rawName();
String rawTriggerEvent = triggerPoint.getEvent();
if (rawTriggerEvent != null && rawTriggerEvent.startsWith("<SYMBOLIC: ") && rawTriggerEvent.endsWith(".*>")) {
String targetType = rawTriggerEvent.substring(11, rawTriggerEvent.length() - 3);
if (smEventRaw.startsWith(targetType + ".")) {
return true;
}
if (smEventRaw.contains(".")) {
String smType = smEventRaw.substring(0, smEventRaw.lastIndexOf('.'));
if (smType.endsWith("." + targetType) || smType.equals(targetType)) {
return true;
}
}
return false;
}
List<String> polyEvents = triggerPoint.getPolymorphicEvents() != null ? triggerPoint.getPolymorphicEvents() : java.util.Collections.emptyList();
for (String pe : polyEvents) {
if (matchEventNames(pe, smEventRaw, triggerPoint.getEventTypeFqn())) {
return true;
}
if (pe.startsWith("<SYMBOLIC: ") && pe.endsWith(".*>")) {
String targetType = pe.substring(11, pe.length() - 3);
if (smEventRaw.startsWith(targetType + ".")) {
return true;
}
if (smEventRaw.contains(".")) {
String smType = smEventRaw.substring(0, smEventRaw.lastIndexOf('.'));
if (smType.endsWith("." + targetType) || smType.equals(targetType)) {
continue;
}
String eventTypeFqn = triggerPoint.getEventTypeFqn();
if (pe.contains(".") && isStringTypeOrPrimitive(eventTypeFqn)) {
eventTypeFqn = null;
}
if (matchEventNames(pe, smEventRaw, eventTypeFqn)) {
return true;
}
if (!pe.contains(".") && smEventRaw.contains(".")) {
String smConst = constantName(smEventRaw);
if (pe.equals(smConst) && triggerPoint.getEventTypeFqn() != null
&& !isStringTypeOrPrimitive(triggerPoint.getEventTypeFqn())) {
String smEnumType = smEventRaw.substring(0, smEventRaw.lastIndexOf('.'));
return enumTypesMatch(triggerPoint.getEventTypeFqn(), smEnumType);
}
}
}
if (rawTriggerEvent != null && rawTriggerEvent.contains(".valueOf(")) {
String enumClass = rawTriggerEvent.substring(0, rawTriggerEvent.indexOf(".valueOf("));
if (enumClass.contains(" ")) {
enumClass = enumClass.substring(enumClass.lastIndexOf(" ") + 1);
}
String smClass = smEventRaw.contains(".") ? smEventRaw.substring(0, smEventRaw.lastIndexOf('.')) : smEventRaw;
if (enumClass.equals(smClass) || enumClass.endsWith("." + smClass) || smClass.endsWith("." + enumClass)) {
return true;
}
return false;
}
@@ -91,7 +81,7 @@ public class StrictFqnMatchingEngine implements EventMatchingEngine {
if (!tConst.equals(smConst)) return false;
// Prevent matching string/primitive triggers to enum transitions
if (isStringTypeOrPrimitive(eventTypeFqn) && smEvent.contains(".")) {
if (isStringTypeOrPrimitive(eventTypeFqn) && smEvent.contains(".") && !triggerEvent.contains(".")) {
return false;
}

View File

@@ -0,0 +1,127 @@
package click.kamil.springstatemachineexporter.analysis.resolver;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Shared boolean simplification for transition guards and dispatcher domain checks.
*/
public final class BooleanConstraintEvaluator {
private static final Pattern STRING_LITERAL_PATTERN = Pattern.compile("[\"']([a-zA-Z0-9_-]+)[\"']");
private BooleanConstraintEvaluator() {
}
/**
* Simplifies constraints such as {@code "ORDER".equals(domain)} against a machine domain key.
*/
public static boolean isCompatibleWithMachineDomain(String constraint, String machineDomainKey) {
if (constraint == null || machineDomainKey == null) {
return true;
}
String cleanMachine = machineDomainKey.toUpperCase();
String expr = constraint;
Set<String> literals = extractStringLiterals(constraint);
for (String literal : literals) {
boolean matchesMachine = literal.equalsIgnoreCase(cleanMachine)
|| cleanMachine.equalsIgnoreCase(literal)
|| (cleanMachine.length() > literal.length() && cleanMachine.startsWith(literal.toUpperCase()));
String replacement = matchesMachine ? "true" : "false";
String regex1 = "(?i)[\"']?" + Pattern.quote(literal) + "[\"']?\\s*\\.\\s*(equalsIgnoreCase|equals)\\s*\\([^)]*\\)";
String regex2 = "(?i)[a-zA-Z0-9._]+\\s*\\.\\s*(equalsIgnoreCase|equals)\\s*\\(\\s*[\"']?"
+ Pattern.quote(literal) + "[\"']?\\s*\\)";
String regex3 = "(?i)[a-zA-Z0-9._]+\\s*==\\s*[\"']?" + Pattern.quote(literal) + "[\"']?";
String regex4 = "(?i)[a-zA-Z0-9._]+\\s*!=\\s*[\"']?" + Pattern.quote(literal) + "[\"']?";
expr = expr.replaceAll(regex1, replacement);
expr = expr.replaceAll(regex2, replacement);
expr = expr.replaceAll(regex3, replacement);
expr = expr.replaceAll(regex4, matchesMachine ? "false" : "true");
}
return evaluateBooleanExpression(expr);
}
public static boolean evaluateBooleanExpression(String expression) {
if (expression == null || expression.isBlank()) {
return true;
}
try {
return parseExpression(expression.replaceAll("\\s+", ""));
} catch (Exception e) {
return !expression.contains("false");
}
}
/**
* Returns {@code true}/{@code false} when both sides are compile-time string literals; otherwise {@code null}.
*/
public static Boolean evaluateStringEquals(String leftLiteral, String rightLiteral, boolean ignoreCase) {
if (leftLiteral == null || rightLiteral == null) {
return null;
}
return ignoreCase
? leftLiteral.equalsIgnoreCase(rightLiteral)
: leftLiteral.equals(rightLiteral);
}
private static Set<String> extractStringLiterals(String constraint) {
Set<String> literals = new HashSet<>();
Matcher matcher = STRING_LITERAL_PATTERN.matcher(constraint);
while (matcher.find()) {
literals.add(matcher.group(1));
}
return literals;
}
private static boolean parseExpression(String expression) {
if (expression.isEmpty()) {
return true;
}
int parenDepth = 0;
for (int i = expression.length() - 1; i >= 0; i--) {
char c = expression.charAt(i);
if (c == ')') {
parenDepth++;
} else if (c == '(') {
parenDepth--;
} else if (parenDepth == 0 && i > 0 && expression.charAt(i) == '|' && expression.charAt(i - 1) == '|') {
return parseExpression(expression.substring(0, i - 1)) || parseExpression(expression.substring(i + 1));
}
}
parenDepth = 0;
for (int i = expression.length() - 1; i >= 0; i--) {
char c = expression.charAt(i);
if (c == ')') {
parenDepth++;
} else if (c == '(') {
parenDepth--;
} else if (parenDepth == 0 && i > 0 && expression.charAt(i) == '&' && expression.charAt(i - 1) == '&') {
return parseExpression(expression.substring(0, i - 1)) && parseExpression(expression.substring(i + 1));
}
}
if (expression.startsWith("!")) {
return !parseExpression(expression.substring(1));
}
if (expression.startsWith("(") && expression.endsWith(")")) {
return parseExpression(expression.substring(1, expression.length() - 1));
}
if ("true".equalsIgnoreCase(expression)) {
return true;
}
if ("false".equalsIgnoreCase(expression)) {
return false;
}
return true;
}
}

View File

@@ -1,9 +1,11 @@
package click.kamil.springstatemachineexporter.analysis.resolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.ast.common.JdtDataFlowModel;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.jdt.core.dom.*;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.Set;
import java.util.LinkedHashSet;
@@ -17,6 +19,10 @@ public class ConstantResolver {
return resolveInternal(expr, context, new HashSet<>());
}
public static List<String> decodeMapLiteralValues(String encoded) {
return LiteralExpressionSupport.mapLiteralValues(encoded);
}
public String evaluateSwitchWithParams(org.eclipse.jdt.core.dom.SwitchExpression se, java.util.Map<String, String> paramValues, CodebaseContext context) {
return evaluateSwitchExpression(se, paramValues, context, new HashSet<>());
}
@@ -35,6 +41,25 @@ public class ConstantResolver {
if (expr instanceof ParenthesizedExpression pe) {
return resolveInternal(pe.getExpression(), context, visited);
}
if (expr instanceof ArrayAccess access) {
String indexed = LiteralExpressionSupport.resolveArrayAccess(
access, context, visited, (e, v) -> resolveInternal(e, context, v));
if (indexed != null) {
return indexed;
}
}
if (expr instanceof ArrayCreation creation && creation.getInitializer() != null) {
List<String> elements = extractArrayElementsFromCreation(creation, context, visited);
if (elements != null) {
return LiteralExpressionSupport.encodeArrayLiteral(elements);
}
}
if (expr instanceof ArrayInitializer initializer) {
List<String> elements = extractArrayElementsFromInitializer(initializer, context, visited);
if (elements != null) {
return LiteralExpressionSupport.encodeArrayLiteral(elements);
}
}
if (expr instanceof MethodInvocation mi && (mi.getName().getIdentifier().equals("requireNonNull") || mi.getName().getIdentifier().equals("notNull"))) {
if (!mi.arguments().isEmpty()) {
return resolveInternal((Expression) mi.arguments().get(0), context, visited);
@@ -76,6 +101,10 @@ public class ConstantResolver {
return val != null ? val : qn.toString();
}
if (expr instanceof FieldAccess fa) {
String staticField = resolveStaticFieldAccess(fa, context, visited);
if (staticField != null) {
return staticField;
}
return resolveManual(fa.getName(), context, visited);
}
if (expr instanceof SimpleName sn) {
@@ -84,7 +113,26 @@ public class ConstantResolver {
if (expr instanceof MethodInvocation mi) {
if ((mi.getName().getIdentifier().equals("name") || mi.getName().getIdentifier().equals("toString")) && mi.arguments().isEmpty()) {
String methodName = mi.getName().getIdentifier();
if ("get".equals(methodName) || "getOrDefault".equals(methodName)) {
String mapValue = LiteralExpressionSupport.resolveMapGet(
mi, context, visited, (e, v) -> resolveInternal(e, context, v));
if (mapValue != null) {
return mapValue;
}
}
String transformed = LiteralExpressionSupport.resolveLiteralStringTransform(
mi, visited, (e, v) -> resolveInternal(e, context, v));
if (transformed != null) {
return transformed;
}
java.util.Map<String, String> mapEntries = LiteralExpressionSupport.extractMapEntries(
mi, context, visited, (e, v) -> resolveInternal(e, context, v));
if (mapEntries != null && isMapFactoryInvocation(mi)) {
return LiteralExpressionSupport.encodeMapLiteral(mapEntries);
}
if ((methodName.equals("name") || methodName.equals("toString")) && mi.arguments().isEmpty()) {
String val = resolveInternal(mi.getExpression(), context, visited);
if (val != null) {
// If it was resolved as a fully qualified enum string like "MyEvents.ORDER_PAID", strip it
@@ -671,6 +719,50 @@ public class ConstantResolver {
return fields.isEmpty() ? null : fields;
}
private List<String> extractArrayElementsFromCreation(ArrayCreation creation, CodebaseContext context, Set<String> visited) {
if (creation.getInitializer() == null) {
return null;
}
List<String> values = new ArrayList<>();
for (Object expressionObj : creation.getInitializer().expressions()) {
String value = resolveInternal((Expression) expressionObj, context, visited);
if (value == null || value.startsWith("ARRAY:") || value.startsWith("MAP:")) {
return null;
}
values.add(value);
}
return values;
}
private List<String> extractArrayElementsFromInitializer(ArrayInitializer initializer, CodebaseContext context, Set<String> visited) {
List<String> values = new ArrayList<>();
for (Object expressionObj : initializer.expressions()) {
String value = resolveInternal((Expression) expressionObj, context, visited);
if (value == null || value.startsWith("ARRAY:") || value.startsWith("MAP:")) {
return null;
}
values.add(value);
}
return values;
}
private boolean isMapFactoryInvocation(MethodInvocation mi) {
if (!"of".equals(mi.getName().getIdentifier()) && !"ofEntries".equals(mi.getName().getIdentifier())) {
return false;
}
Expression receiver = mi.getExpression();
if (receiver == null) {
return false;
}
if (receiver instanceof SimpleName sn) {
return "Map".equals(sn.getIdentifier());
}
if (receiver instanceof QualifiedName qn) {
return "Map".equals(qn.getName().getIdentifier());
}
return false;
}
private String resolveInfix(InfixExpression expr, CodebaseContext context, Set<String> visited) {
if (expr.getOperator() != InfixExpression.Operator.PLUS) return null;
@@ -721,7 +813,18 @@ public class ConstantResolver {
if (td != null) {
String fqn = context.getFqn(td);
String result = resolveFieldInType(td, sn.getIdentifier(), fqn, context, visited);
if (result != null) return result;
if (result != null) {
return result;
}
}
td = typeFromCurrentCallPath(context);
if (td != null) {
String fqn = context.getFqn(td);
String result = resolveFieldInType(td, sn.getIdentifier(), fqn, context, visited);
if (result != null) {
return result;
}
}
// Fallback: Static imports
@@ -742,6 +845,9 @@ public class ConstantResolver {
CompilationUnit contextCu = (CompilationUnit) qn.getRoot();
TypeDeclaration td = context.getTypeDeclaration(qualifier, contextCu);
if (td == null) {
td = resolveTypeInContext(qualifier, qn, context);
}
if (td != null) {
String fqn = context.getFqn(td);
@@ -751,6 +857,67 @@ public class ConstantResolver {
return null;
}
private String resolveStaticFieldAccess(FieldAccess fa, CodebaseContext context, Set<String> visited) {
Expression receiver = fa.getExpression();
if (receiver instanceof ThisExpression) {
return null;
}
String typeName = null;
if (receiver instanceof SimpleName sn) {
typeName = sn.getIdentifier();
} else if (receiver instanceof QualifiedName qn) {
typeName = qn.getFullyQualifiedName();
}
if (typeName == null) {
return null;
}
TypeDeclaration td = resolveTypeInContext(typeName, fa, context);
if (td == null) {
td = typeFromCurrentCallPath(context);
}
if (td == null) {
return null;
}
return resolveFieldInType(td, fa.getName().getIdentifier(), context.getFqn(td), context, visited);
}
private TypeDeclaration resolveTypeInContext(String typeName, ASTNode node, CodebaseContext context) {
CompilationUnit contextCu = node.getRoot() instanceof CompilationUnit cu ? cu : null;
TypeDeclaration td = contextCu != null
? context.getTypeDeclaration(typeName, contextCu)
: context.getTypeDeclaration(typeName);
if (td != null) {
return td;
}
String entryClass = getCurrentCallPathEntryClass();
if (entryClass == null) {
return null;
}
TypeDeclaration entryTd = context.getTypeDeclaration(entryClass);
if (entryTd == null || !(entryTd.getRoot() instanceof CompilationUnit entryCu)) {
return null;
}
return context.getTypeDeclaration(typeName, entryCu);
}
private TypeDeclaration typeFromCurrentCallPath(CodebaseContext context) {
String entryClass = getCurrentCallPathEntryClass();
if (entryClass == null) {
return null;
}
return context.getTypeDeclaration(entryClass);
}
private String getCurrentCallPathEntryClass() {
List<String> path = JdtDataFlowModel.getCurrentPath();
if (path == null || path.isEmpty()) {
return null;
}
String entryMethod = path.get(0);
int dot = entryMethod.lastIndexOf('.');
return dot > 0 ? entryMethod.substring(0, dot) : null;
}
private String resolveFieldInType(TypeDeclaration td, String fieldName, String typeFqn, CodebaseContext context, Set<String> visited) {
String fieldId = typeFqn + "#" + fieldName;
if (visited.contains(fieldId)) {

View File

@@ -0,0 +1,304 @@
package click.kamil.springstatemachineexporter.analysis.resolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.*;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.function.BiFunction;
/**
* Compile-time literal folding for map lookups, array indexing, and narrow string transforms.
*/
final class LiteralExpressionSupport {
private LiteralExpressionSupport() {
}
static String resolveArrayAccess(
ArrayAccess access,
CodebaseContext context,
Set<String> visited,
BiFunction<Expression, Set<String>, String> resolver) {
Integer index = resolveLiteralInt(access.getIndex(), context, visited, resolver);
if (index == null || index < 0) {
return null;
}
List<String> elements = extractArrayElements(access.getArray(), context, visited, resolver);
if (elements == null || index >= elements.size()) {
return null;
}
return elements.get(index);
}
static String resolveMapGet(
MethodInvocation getCall,
CodebaseContext context,
Set<String> visited,
BiFunction<Expression, Set<String>, String> resolver) {
if (getCall.arguments().isEmpty() || getCall.getExpression() == null) {
return null;
}
String key = resolver.apply((Expression) getCall.arguments().get(0), visited);
if (key == null) {
return null;
}
Map<String, String> entries = extractMapEntries(getCall.getExpression(), context, visited, resolver);
if (entries == null) {
return null;
}
return entries.get(key);
}
static String resolveLiteralStringTransform(
MethodInvocation transformCall,
Set<String> visited,
BiFunction<Expression, Set<String>, String> resolver) {
if (!transformCall.arguments().isEmpty() || transformCall.getExpression() == null) {
return null;
}
String receiver = resolver.apply(transformCall.getExpression(), visited);
if (receiver == null) {
return null;
}
return switch (transformCall.getName().getIdentifier()) {
case "toUpperCase" -> receiver.toUpperCase(Locale.ROOT);
case "toLowerCase" -> receiver.toLowerCase(Locale.ROOT);
case "trim" -> receiver.trim();
default -> null;
};
}
static Integer resolveLiteralInt(
Expression expr,
CodebaseContext context,
Set<String> visited,
BiFunction<Expression, Set<String>, String> resolver) {
if (expr instanceof NumberLiteral nl) {
try {
return Integer.parseInt(nl.getToken().replace("_", ""));
} catch (NumberFormatException e) {
return null;
}
}
if (expr instanceof ParenthesizedExpression pe) {
return resolveLiteralInt(pe.getExpression(), context, visited, resolver);
}
if (expr instanceof InfixExpression infix) {
Integer left = resolveLiteralInt(infix.getLeftOperand(), context, visited, resolver);
Integer right = resolveLiteralInt(infix.getRightOperand(), context, visited, resolver);
if (left == null || right == null) {
return null;
}
InfixExpression.Operator op = infix.getOperator();
if (op == InfixExpression.Operator.PLUS) {
return left + right;
}
if (op == InfixExpression.Operator.MINUS) {
return left - right;
}
}
String resolved = resolver.apply(expr, visited);
if (resolved != null) {
try {
return Integer.parseInt(resolved.replace("_", ""));
} catch (NumberFormatException ignored) {
return null;
}
}
return null;
}
static Map<String, String> extractMapEntries(
Expression expr,
CodebaseContext context,
Set<String> visited,
BiFunction<Expression, Set<String>, String> resolver) {
if (expr instanceof MethodInvocation mi) {
Map<String, String> fromFactory = extractMapFactoryEntries(mi, context, visited, resolver);
if (fromFactory != null) {
return fromFactory;
}
return null;
}
String resolvedField = resolver.apply(expr, visited);
if (resolvedField != null && resolvedField.startsWith("MAP:")) {
return parseMapLiteral(resolvedField);
}
return null;
}
private static Map<String, String> extractMapFactoryEntries(
MethodInvocation mi,
CodebaseContext context,
Set<String> visited,
BiFunction<Expression, Set<String>, String> resolver) {
if (!isMapFactory(mi.getExpression())) {
return null;
}
String method = mi.getName().getIdentifier();
if ("of".equals(method)) {
return extractAlternatingPairs(mi.arguments(), context, visited, resolver);
}
if ("ofEntries".equals(method)) {
Map<String, String> entries = new LinkedHashMap<>();
for (Object argObj : mi.arguments()) {
if (argObj instanceof Expression entryExpr) {
Map<String, String> single = extractMapEntryCall(entryExpr, context, visited, resolver);
if (single != null) {
entries.putAll(single);
}
}
}
return entries.isEmpty() ? null : entries;
}
return null;
}
private static Map<String, String> extractMapEntryCall(
Expression expr,
CodebaseContext context,
Set<String> visited,
BiFunction<Expression, Set<String>, String> resolver) {
if (!(expr instanceof MethodInvocation entryCall) || !"entry".equals(entryCall.getName().getIdentifier())) {
return null;
}
if (!isMapFactory(entryCall.getExpression()) || entryCall.arguments().size() != 2) {
return null;
}
String key = resolver.apply((Expression) entryCall.arguments().get(0), visited);
String value = resolver.apply((Expression) entryCall.arguments().get(1), visited);
if (key == null || value == null) {
return null;
}
return Map.of(key, value);
}
private static Map<String, String> extractAlternatingPairs(
List<?> arguments,
CodebaseContext context,
Set<String> visited,
BiFunction<Expression, Set<String>, String> resolver) {
if (arguments.size() < 2) {
return null;
}
Map<String, String> entries = new LinkedHashMap<>();
for (int i = 0; i + 1 < arguments.size(); i += 2) {
String key = resolver.apply((Expression) arguments.get(i), visited);
String value = resolver.apply((Expression) arguments.get(i + 1), visited);
if (key == null || value == null) {
return null;
}
entries.put(key, value);
}
return entries;
}
static String encodeMapLiteral(Map<String, String> entries) {
StringBuilder sb = new StringBuilder("MAP:");
boolean first = true;
for (Map.Entry<String, String> entry : entries.entrySet()) {
if (!first) {
sb.append('|');
}
first = false;
sb.append(escape(entry.getKey())).append('=').append(escape(entry.getValue()));
}
return sb.toString();
}
static Map<String, String> parseMapLiteral(String encoded) {
if (encoded == null || !encoded.startsWith("MAP:")) {
return null;
}
Map<String, String> entries = new LinkedHashMap<>();
for (String pair : encoded.substring(4).split("\\|")) {
int eq = pair.indexOf('=');
if (eq <= 0) {
continue;
}
entries.put(unescape(pair.substring(0, eq)), unescape(pair.substring(eq + 1)));
}
return entries.isEmpty() ? null : entries;
}
static List<String> mapLiteralValues(String encoded) {
Map<String, String> entries = parseMapLiteral(encoded);
if (entries == null) {
return null;
}
return List.copyOf(entries.values());
}
private static String escape(String value) {
return value.replace("\\", "\\\\").replace("|", "\\|").replace("=", "\\=");
}
private static String unescape(String value) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < value.length(); i++) {
char c = value.charAt(i);
if (c == '\\' && i + 1 < value.length()) {
sb.append(value.charAt(++i));
} else {
sb.append(c);
}
}
return sb.toString();
}
private static List<String> extractArrayElements(
Expression arrayExpr,
CodebaseContext context,
Set<String> visited,
BiFunction<Expression, Set<String>, String> resolver) {
if (arrayExpr instanceof ArrayCreation creation && creation.getInitializer() != null) {
return extractExpressionList(creation.getInitializer().expressions(), context, visited, resolver);
}
if (arrayExpr instanceof ArrayInitializer initializer) {
return extractExpressionList(initializer.expressions(), context, visited, resolver);
}
String resolved = resolver.apply(arrayExpr, visited);
if (resolved != null && resolved.startsWith("ARRAY:")) {
return List.of(resolved.substring(6).split("\\|", -1));
}
return null;
}
static String encodeArrayLiteral(List<String> values) {
return "ARRAY:" + String.join("|", values.stream().map(LiteralExpressionSupport::escape).toList());
}
private static List<String> extractExpressionList(
List<?> expressions,
CodebaseContext context,
Set<String> visited,
BiFunction<Expression, Set<String>, String> resolver) {
List<String> values = new ArrayList<>();
for (Object expressionObj : expressions) {
String value = resolver.apply((Expression) expressionObj, visited);
if (value == null) {
return null;
}
values.add(value);
}
return values;
}
private static boolean isMapFactory(Expression expression) {
if (expression == null) {
return false;
}
if (expression instanceof SimpleName sn) {
return "Map".equals(sn.getIdentifier());
}
if (expression instanceof QualifiedName qn) {
return "Map".equals(qn.getName().getIdentifier());
}
return false;
}
}

View File

@@ -21,6 +21,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
protected final VariableTracer variableTracer;
protected final ConstantExtractor constantExtractor;
protected final ConstructorAnalyzer constructorAnalyzer;
protected final ExpressionAccessClassifier expressionAccessClassifier;
private final Map<String, ASTNode> parsedNodeCache = new HashMap<>();
private ASTNode parseExpressionString(String expr) {
@@ -77,6 +78,9 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
this.constantExtractor.setConstructorAnalyzer(this.constructorAnalyzer);
this.constructorAnalyzer.setVariableTracer(this.variableTracer);
this.constructorAnalyzer.setConstantExtractor(this.constantExtractor);
this.expressionAccessClassifier = new ExpressionAccessClassifier(
context, variableTracer, constantResolver, this::parseExpressionString);
this.constantExtractor.setExpressionAccessClassifier(this.expressionAccessClassifier);
}
public List<CallChain> findChains(List<EntryPoint> entryPoints, List<TriggerPoint> triggers) {
@@ -156,7 +160,8 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
String currentParamName = event;
String resolvedValue = event;
String methodSuffix = "";
String[] extractedEntry = extractMethodSuffix(currentParamName, methodSuffix);
String scopeMethod = path.isEmpty() ? null : path.get(0);
String[] extractedEntry = extractMethodSuffix(currentParamName, methodSuffix, scopeMethod);
currentParamName = extractedEntry[0];
methodSuffix = extractedEntry[1];
boolean isAmbiguous = false;
@@ -197,7 +202,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
Map<String, String> parameterValues = variableTracer.buildParameterValuesMap(caller, target, callGraph, path, i);
String tracedVar = variableTracer.traceLocalVariable(target, currentParamName, parameterValues);
if (tracedVar != null && !tracedVar.equals(currentParamName)) {
String[] extractedTraced = extractMethodSuffix(tracedVar, methodSuffix);
String[] extractedTraced = extractMethodSuffix(tracedVar, methodSuffix, target);
tracedVar = extractedTraced[0];
methodSuffix = extractedTraced[1];
currentParamName = tracedVar;
@@ -259,7 +264,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
}
// Intentionally not replacing 'super' because super should remain statically bound to the superclass of where the code is defined.
}
String[] extractedArg = extractMethodSuffix(arg, methodSuffix);
String[] extractedArg = extractMethodSuffix(arg, methodSuffix, caller);
arg = extractedArg[0];
methodSuffix = extractedArg[1];
currentParamName = arg;
@@ -290,11 +295,67 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
}
String entryMethod = path.get(0);
if (methodSuffix.startsWith(".get") || methodSuffix.contains(".type") || methodSuffix.contains(".event")) {
List<String> chainedGetterEvents = evaluateGetterOnReceiver(resolvedValue, methodSuffix, entryMethod, path, callGraph);
if (chainedGetterEvents != null && !chainedGetterEvents.isEmpty()) {
log.debug("Early return (chained getter): getterEvents = {}", chainedGetterEvents);
return TriggerPoint.builder()
.event(resolvedValue)
.className(tp.getClassName())
.methodName(tp.getMethodName())
.sourceFile(tp.getSourceFile())
.sourceModule(tp.getSourceModule())
.stateMachineId(tp.getStateMachineId())
.sourceState(tp.getSourceState())
.lineNumber(tp.getLineNumber())
.polymorphicEvents(chainedGetterEvents)
.constraint(tp.getConstraint())
.stateTypeFqn(tp.getStateTypeFqn())
.eventTypeFqn(tp.getEventTypeFqn())
.external(tp.isExternal())
.build();
}
if (methodSuffix.startsWith(".get") || methodSuffix.equals(".type") || methodSuffix.equals(".event")
|| methodSuffix.equals(".type()") || methodSuffix.equals(".event()")) {
String localMethodName = methodSuffix.substring(1).replace("()", "");
String scopeForVar = resolveScopeMethodForVariable(currentParamName, entryMethod, path);
Expression localSetterExpr = variableTracer.traceLocalSetter(scopeForVar, currentParamName, localMethodName);
if (localSetterExpr != null) {
List<String> setterEvents = new ArrayList<>();
List<Expression> tracedSetters = variableTracer.traceVariableAll(localSetterExpr);
for (Expression tracedSetter : tracedSetters) {
constantExtractor.extractConstantsFromExpression(tracedSetter, setterEvents);
}
if (!setterEvents.isEmpty()) {
log.debug("Early return (scoped setter): setterEvents = {}", setterEvents);
return TriggerPoint.builder()
.event(resolvedValue)
.className(tp.getClassName())
.methodName(tp.getMethodName())
.sourceFile(tp.getSourceFile())
.sourceModule(tp.getSourceModule())
.stateMachineId(tp.getStateMachineId())
.sourceState(tp.getSourceState())
.lineNumber(tp.getLineNumber())
.polymorphicEvents(setterEvents)
.constraint(tp.getConstraint())
.stateTypeFqn(tp.getStateTypeFqn())
.eventTypeFqn(tp.getEventTypeFqn())
.external(tp.isExternal())
.build();
}
}
}
}
int entryParamIndex = typeResolver.getParameterIndex(entryMethod, currentParamName);
if (entryParamIndex < 0) {
if (methodSuffix.startsWith(".get") || methodSuffix.equals(".type") || methodSuffix.equals(".event") || methodSuffix.equals(".type()") || methodSuffix.equals(".event()")) {
String localMethodName = methodSuffix.substring(1).replace("()", "");
Expression localSetterExpr = variableTracer.traceLocalSetter(entryMethod, currentParamName, localMethodName);
String scopeForVar = resolveScopeMethodForVariable(currentParamName, entryMethod, path);
Expression localSetterExpr = variableTracer.traceLocalSetter(scopeForVar, currentParamName, localMethodName);
if (localSetterExpr != null) {
List<String> setterEvents = new ArrayList<>();
List<Expression> tracedSetters = variableTracer.traceVariableAll(localSetterExpr);
@@ -304,7 +365,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
if (!setterEvents.isEmpty()) {
log.debug("Early return 1: setterEvents = {}", setterEvents);
return TriggerPoint.builder()
.event(tp.getEvent())
.event(resolvedValue)
.className(tp.getClassName())
.methodName(tp.getMethodName())
.sourceFile(tp.getSourceFile())
@@ -329,7 +390,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
if (getterEvents != null && !getterEvents.isEmpty()) {
log.debug("Early return 2: getterEvents = {}", getterEvents);
return TriggerPoint.builder()
.event(tp.getEvent())
.event(resolvedValue)
.className(tp.getClassName())
.methodName(tp.getMethodName())
.sourceFile(tp.getSourceFile())
@@ -348,7 +409,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
String tracedVar = variableTracer.traceLocalVariable(entryMethod, currentParamName);
if (tracedVar != null && !tracedVar.equals(currentParamName)) {
String[] extractedFinalTraced = extractMethodSuffix(tracedVar, methodSuffix);
String[] extractedFinalTraced = extractMethodSuffix(tracedVar, methodSuffix, entryMethod);
tracedVar = extractedFinalTraced[0];
methodSuffix = extractedFinalTraced[1];
currentParamName = tracedVar;
@@ -366,7 +427,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
if (!polymorphicEvents.contains(parsed)) polymorphicEvents.add(parsed);
}
return TriggerPoint.builder()
.event(tp.getEvent())
.event(resolvedValue)
.className(tp.getClassName())
.methodName(tp.getMethodName())
.sourceFile(tp.getSourceFile())
@@ -394,17 +455,62 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
if (exprNode instanceof MethodInvocation mi) {
methodName = mi.getName().getIdentifier();
if ("get".equals(methodName) || "getOrDefault".equals(methodName)) {
boolean keyedLookup = expressionAccessClassifier.isKeyedLookup(mi, entryMethod);
boolean functionalGet = "get".equals(methodName) && mi.arguments().isEmpty() && !keyedLookup;
if (keyedLookup || functionalGet) {
constantExtractor.extractConstantsFromExpression((Expression) exprNode, polymorphicEvents);
if (!polymorphicEvents.isEmpty()) {
return TriggerPoint.builder()
.event(resolvedValue)
.className(tp.getClassName())
.methodName(tp.getMethodName())
.sourceFile(tp.getSourceFile())
.sourceModule(tp.getSourceModule())
.stateMachineId(tp.getStateMachineId())
.sourceState(tp.getSourceState())
.lineNumber(tp.getLineNumber())
.polymorphicEvents(polymorphicEvents)
.constraint(tp.getConstraint())
.stateTypeFqn(tp.getStateTypeFqn())
.eventTypeFqn(tp.getEventTypeFqn())
.external(tp.isExternal())
.ambiguous(isAmbiguous)
.build();
}
}
}
boolean handledStaticEnum = false;
if (methodName.equals("valueOf") && mi.arguments().size() == 1) {
Object arg = mi.arguments().get(0);
String enumTypeName = resolveValueOfEnumTypeName(mi);
if (arg instanceof StringLiteral sl) {
if (enumTypeName != null) {
polymorphicEvents.add(enumTypeName + "." + sl.getLiteralValue());
} else {
polymorphicEvents.add(sl.getLiteralValue());
}
methodName = null;
handledStaticEnum = true;
} else if (arg instanceof QualifiedName qn) {
if (enumTypeName != null) {
polymorphicEvents.add(enumTypeName + "." + qn.getName().getIdentifier());
} else {
polymorphicEvents.add(qn.getName().getIdentifier());
}
methodName = null;
handledStaticEnum = true;
} else if (arg instanceof SimpleName sn) {
String resolvedConstant = resolveEnumConstantFromArgument(sn.getIdentifier(), path, callGraph);
if (resolvedConstant != null) {
if (enumTypeName != null) {
polymorphicEvents.add(enumTypeName + "." + resolvedConstant);
} else {
polymorphicEvents.add(resolvedConstant);
}
methodName = null;
handledStaticEnum = true;
}
}
} else if (methodName.equals("name") && mi.arguments().isEmpty()) {
if (mi.getExpression() instanceof QualifiedName qn) {
@@ -444,7 +550,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
}
}
return TriggerPoint.builder()
.event(tp.getEvent())
.event(resolvedValue)
.className(tp.getClassName())
.methodName(tp.getMethodName())
.sourceFile(tp.getSourceFile())
@@ -579,7 +685,8 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
if (methodName != null) {
if (varName != null && !varName.isEmpty() && Character.isLowerCase(varName.charAt(0)) && !methodName.equals("VariableReference")) {
Expression localSetterExpr = variableTracer.traceLocalSetter(entryMethod, varName, methodName);
String scopeForVar = resolveScopeMethodForVariable(varName, entryMethod, path);
Expression localSetterExpr = variableTracer.traceLocalSetter(scopeForVar, varName, methodName);
if (localSetterExpr != null) {
List<Expression> tracedSetters = variableTracer.traceVariableAll(localSetterExpr);
for (Expression tracedSetter : tracedSetters) {
@@ -724,10 +831,14 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
if (declaredType != null) {
List<String> enumValues = context.getEnumValues(declaredType);
if (enumValues != null && !enumValues.isEmpty()) {
if (!hasConcreteEnumConstants(polymorphicEvents, declaredType, context)
&& !tp.isExternal()
&& !isRuntimeEnumParameter(exprNode instanceof Expression expression ? expression : null)) {
for (String ev : enumValues) {
polymorphicEvents.add(constantExtractor.parseEnumSetElement(ev));
}
isAmbiguous = true;
}
} else {
List<String> typesToInspect = new ArrayList<>();
if ("inline-instantiation".equals(sourceMethod)) {
@@ -850,6 +961,11 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
polymorphicEvents = polymorphicEvents.stream().distinct().collect(java.util.stream.Collectors.toList());
Expression runtimeExpr = exprNode instanceof Expression expression ? expression : null;
if (isRuntimeEnumParameter(runtimeExpr) || tp.isExternal()) {
polymorphicEvents.removeIf(pe -> pe != null && pe.startsWith("<SYMBOLIC:"));
}
if (!resolvedValue.equals(event) || !polymorphicEvents.isEmpty()) {
return TriggerPoint.builder()
.event(resolvedValue)
@@ -1030,12 +1146,108 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
return constructorAnalyzer.findReturnedCIC(className, methodName, context);
}
protected record GetterChainReceiver(String typeFqn, ClassInstanceCreation cic) {}
/**
* Resolves a nested getter prefix such as {@code wrapper.getEvent()} to the declared return type
* and, when possible, a {@code new ...()} instance from the getter body.
*/
protected GetterChainReceiver resolveGetterChainToReceiver(Expression expr, String entryMethod) {
if (expr instanceof SimpleName sn) {
String type = variableTracer.getVariableDeclaredType(entryMethod, sn.getIdentifier());
if (type == null) {
return null;
}
Expression init = findVariableInitializer(entryMethod, sn.getIdentifier());
ClassInstanceCreation cic = init instanceof ClassInstanceCreation c ? c : null;
return new GetterChainReceiver(type, cic);
}
if (expr instanceof ThisExpression) {
int lastDot = entryMethod.lastIndexOf('.');
if (lastDot <= 0) {
return null;
}
String className = entryMethod.substring(0, lastDot);
return new GetterChainReceiver(className, null);
}
if (expr instanceof ClassInstanceCreation cic) {
return new GetterChainReceiver(
click.kamil.springstatemachineexporter.ast.common.AstUtils.extractSimpleTypeName(cic.getType()),
cic);
}
if (!(expr instanceof MethodInvocation mi)) {
return null;
}
GetterChainReceiver base = resolveGetterChainToReceiver(mi.getExpression(), entryMethod);
if (base == null) {
return null;
}
String getterName = mi.getName().getIdentifier();
String simpleType = simplifyTypeName(base.typeFqn());
ClassInstanceCreation nextCic = base.cic();
if (nextCic == null) {
nextCic = constructorAnalyzer.findReturnedCIC(simpleType, getterName, context);
}
if (nextCic != null) {
return new GetterChainReceiver(
click.kamil.springstatemachineexporter.ast.common.AstUtils.extractSimpleTypeName(nextCic.getType()),
nextCic);
}
TypeDeclaration td = context.getTypeDeclaration(simpleType);
if (td == null) {
td = context.getTypeDeclaration(base.typeFqn());
}
if (td != null) {
MethodDeclaration getter = context.findMethodDeclaration(td, getterName, true);
if (getter != null && getter.getReturnType2() != null) {
return new GetterChainReceiver(getter.getReturnType2().toString(), null);
}
}
return null;
}
private static String simplifyTypeName(String typeFqn) {
if (typeFqn == null) {
return null;
}
String simple = typeFqn.contains(".") ? typeFqn.substring(typeFqn.lastIndexOf('.') + 1) : typeFqn;
if (simple.contains("<")) {
simple = simple.substring(0, simple.indexOf('<'));
}
return simple;
}
private String resolveScopeMethodForVariable(String varName, String entryMethod, List<String> path) {
if (varName != null && path != null) {
for (String methodFqn : path) {
if (variableTracer.getVariableDeclaredType(methodFqn, varName) != null) {
return methodFqn;
}
}
}
return entryMethod;
}
protected List<String> evaluateGetterOnReceiver(String resolvedValue, String methodSuffix, String entryMethod, List<String> path, Map<String, List<CallEdge>> callGraph) {
ASTNode exprNode = parseExpressionString(resolvedValue);
while (exprNode instanceof ParenthesizedExpression pe) {
exprNode = pe.getExpression();
}
String scopeMethod = entryMethod;
Expression rootExpr = exprNode instanceof Expression e ? e : null;
if (rootExpr != null) {
while (rootExpr instanceof MethodInvocation mi) {
rootExpr = mi.getExpression();
}
if (rootExpr instanceof SimpleName sn) {
scopeMethod = resolveScopeMethodForVariable(sn.getIdentifier(), entryMethod, path);
}
}
if (exprNode instanceof SuperMethodInvocation smi) {
String getterName = smi.getName().getIdentifier();
int lastDot = entryMethod.lastIndexOf('.');
@@ -1055,11 +1267,16 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
if (receiver instanceof SimpleName sn) {
receiverName = sn.getIdentifier();
receiverType = variableTracer.getVariableDeclaredType(entryMethod, receiverName);
receiverType = variableTracer.getVariableDeclaredType(scopeMethod, receiverName);
} else if (receiver instanceof ClassInstanceCreation cic) {
directCic = cic;
receiverType = cic.getType().toString();
} else if (receiver instanceof MethodInvocation receiverMi) {
GetterChainReceiver chainedReceiver = resolveGetterChainToReceiver(receiverMi, scopeMethod);
if (chainedReceiver != null) {
receiverType = chainedReceiver.typeFqn();
directCic = chainedReceiver.cic();
} else {
Expression currentMi = receiverMi;
while (currentMi instanceof MethodInvocation chainMi) {
String mName = chainMi.getName().getIdentifier();
@@ -1075,6 +1292,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
}
currentMi = chainMi.getExpression();
}
}
} else if (receiver instanceof SuperMethodInvocation) {
int lastDot = entryMethod.lastIndexOf('.');
String className = lastDot > 0 ? entryMethod.substring(0, lastDot) : entryMethod;
@@ -1123,7 +1341,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
if (directCic != null) {
cic = directCic;
} else {
Expression initExpr = findVariableInitializer(entryMethod, varName);
Expression initExpr = findVariableInitializer(scopeMethod, varName);
if (initExpr instanceof MethodInvocation initMi) {
initExpr = unwrapMethodInvocation(initMi, 0, entryMethod);
}
@@ -1148,18 +1366,6 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
}
if (getter != null && getter.getBody() != null) {
fieldValues.putAll(constructorAnalyzer.buildFieldValuesFromCIC(td, cic, context, this::evaluateMethodCallInConstructor));
int lastDotEntry = entryMethod.lastIndexOf('.');
if (lastDotEntry > 0) {
String className = entryMethod.substring(0, lastDotEntry);
String methodName = entryMethod.substring(lastDot + 1);
TypeDeclaration entryTd = context.getTypeDeclaration(className);
if (entryTd != null) {
MethodDeclaration entryMd = context.findMethodDeclaration(entryTd, methodName, false);
if (entryMd != null && entryMd.getBody() != null) {
// Skip trackSetterCallsBetween
}
}
}
String result = constantResolver.evaluateMethodBodyWithLocals(getter, fieldValues, context, new HashSet<>());
if (result != null) {
String returnType = getter.getReturnType2() != null ? getter.getReturnType2().toString() : null;
@@ -1208,7 +1414,22 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
return initExpr[0];
}
/**
* Splits a traced argument into a receiver base and a trailing accessor suffix for getter-chain
* resolution, e.g. {@code wrapper.getEvent()} → {@code ["wrapper", ".getEvent()"]}.
* <p>
* Map/collection lookups ({@code ROUTES.get(key)}) stay intact and are resolved by
* {@link ConstantExtractor}; see {@link ExpressionAccessClassifier} for the type-based distinction
* from {@code Supplier.get()} and JavaBean accessors.
*/
protected String[] extractMethodSuffix(String paramName, String currentSuffix) {
return extractMethodSuffix(paramName, currentSuffix, null);
}
protected String[] extractMethodSuffix(String paramName, String currentSuffix, String scopeMethod) {
if (expressionAccessClassifier.isKeyedLookup(paramName, scopeMethod)) {
return new String[] { paramName, currentSuffix };
}
if (paramName != null && !paramName.contains(" ") && !paramName.startsWith("new ")) {
int bracketIndex = paramName.indexOf('[');
int dotIndex = -1;
@@ -1591,12 +1812,22 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
return allResolved;
}
/**
* Peels enum factory wrappers while tracing call-path arguments, e.g.
* {@code OrderEvent.valueOf(action)} → {@code action} so the next hop can resolve the parameter.
* <p>
* Map/collection lookups ({@code ROUTES.get(key)}) are intentionally <em>not</em> peeled — they are
* routing tables, not enum converters. See {@link ExpressionAccessClassifier}.
*/
private String unwrapConverterMethods(String arg) {
if (arg == null) return null;
String current = arg;
while (current.contains("(") && !current.startsWith("new ")) {
org.eclipse.jdt.core.dom.ASTNode argNode = parseExpressionString(current);
if (argNode instanceof org.eclipse.jdt.core.dom.MethodInvocation miArg && !miArg.arguments().isEmpty()) {
if (expressionAccessClassifier.isKeyedLookup(miArg, null)) {
break;
}
boolean shouldUnpack = false;
org.eclipse.jdt.core.dom.Expression recv = miArg.getExpression();
if (recv == null || recv instanceof org.eclipse.jdt.core.dom.ThisExpression) {
@@ -1619,4 +1850,118 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
}
return current;
}
private String resolveValueOfEnumTypeName(MethodInvocation mi) {
if (mi.getExpression() instanceof SimpleName sn) {
return sn.getIdentifier();
}
if (mi.getExpression() instanceof QualifiedName qn) {
String full = qn.getFullyQualifiedName();
return full.contains(".") ? full.substring(full.lastIndexOf('.') + 1) : full;
}
return null;
}
private String resolveEnumConstantFromArgument(String argName, List<String> path, Map<String, List<CallEdge>> callGraph) {
if (argName == null || path == null || path.size() < 2) {
return null;
}
for (int i = path.size() - 1; i > 0; i--) {
String target = path.get(i);
String caller = path.get(i - 1);
Map<String, String> parameterValues = variableTracer.buildParameterValuesMap(caller, target, callGraph, path, i);
String traced = variableTracer.traceLocalVariable(target, argName, parameterValues);
if (traced != null && !traced.equals(argName)) {
String normalized = normalizeEnumConstantName(traced);
if (normalized != null) {
return normalized;
}
}
int paramIndex = typeResolver.getParameterIndex(target, argName);
if (paramIndex >= 0) {
List<CallEdge> edges = callGraph.get(caller);
if (edges != null) {
for (CallEdge edge : edges) {
if (edge.getTargetMethod().equals(target) || pathFinder.isHeuristicMatch(edge.getTargetMethod(), target)) {
if (paramIndex < edge.getArguments().size()) {
String normalized = normalizeEnumConstantName(edge.getArguments().get(paramIndex));
if (normalized != null) {
return normalized;
}
}
}
}
}
}
}
return null;
}
private String normalizeEnumConstantName(String value) {
if (value == null || value.isBlank()) {
return null;
}
String trimmed = value.trim();
if (trimmed.startsWith("\"") && trimmed.endsWith("\"")) {
trimmed = trimmed.substring(1, trimmed.length() - 1);
}
if (trimmed.contains("(") || trimmed.contains(" ")) {
return null;
}
if (trimmed.contains(".")) {
trimmed = trimmed.substring(trimmed.lastIndexOf('.') + 1);
}
if (!trimmed.matches("[A-Z_][A-Z0-9_]*")) {
return null;
}
return trimmed;
}
private boolean hasConcreteEnumConstants(List<String> polymorphicEvents, String declaredType, CodebaseContext context) {
if (polymorphicEvents == null || polymorphicEvents.isEmpty() || declaredType == null) {
return false;
}
String simpleDeclared = declaredType.contains(".")
? declaredType.substring(declaredType.lastIndexOf('.') + 1)
: declaredType;
List<String> enumValues = context.getEnumValues(declaredType);
for (String pe : polymorphicEvents) {
if (pe == null || pe.startsWith("<SYMBOLIC:")) {
continue;
}
if (pe.contains(".")) {
String typePart = pe.substring(0, pe.lastIndexOf('.'));
String simpleType = typePart.contains(".")
? typePart.substring(typePart.lastIndexOf('.') + 1)
: typePart;
if (simpleType.equals(simpleDeclared)) {
return true;
}
if (enumValues != null && enumValues.contains(pe)) {
return true;
}
} else if (enumValues != null) {
for (String ev : enumValues) {
if (ev.endsWith("." + pe)) {
return true;
}
}
}
}
return false;
}
private boolean isRuntimeEnumParameter(Expression exprNode) {
if (!(exprNode instanceof MethodInvocation mi)) {
return false;
}
if (!"valueOf".equals(mi.getName().getIdentifier()) || mi.arguments().size() != 1) {
return false;
}
return mi.arguments().get(0) instanceof SimpleName;
}
}

View File

@@ -14,6 +14,7 @@ public class ConstantExtractor {
private final Function<MethodInvocation, String> calledMethodResolver;
private VariableTracer variableTracer;
private ConstructorAnalyzer constructorAnalyzer;
private ExpressionAccessClassifier expressionAccessClassifier;
public ConstantExtractor(CodebaseContext context, ConstantResolver constantResolver, Function<MethodInvocation, String> calledMethodResolver) {
this.context = context;
@@ -29,25 +30,20 @@ public class ConstantExtractor {
this.constructorAnalyzer = constructorAnalyzer;
}
public void setExpressionAccessClassifier(ExpressionAccessClassifier expressionAccessClassifier) {
this.expressionAccessClassifier = expressionAccessClassifier;
}
public void extractConstantsFromExpression(Expression expr, List<String> constants) {
if (constantResolver != null) {
String resolved = constantResolver.resolve(expr, context);
if (resolved != null) {
if (resolved.startsWith("ENUM_SET:")) {
for (String eVal : resolved.substring(9).split(",")) {
String parsed = parseEnumSetElement(eVal);
if (!constants.contains(parsed)) constants.add(parsed);
}
} else {
constants.add(resolved);
}
addResolvedConstant(resolved, constants);
return;
}
}
if (expr instanceof QualifiedName qn) {
constants.add(qn.toString());
} else if (expr instanceof StringLiteral sl) {
if (expr instanceof StringLiteral sl) {
constants.add(sl.getLiteralValue());
} else if (expr instanceof ConditionalExpression ce) {
extractConstantsFromExpression(ce.getThenExpression(), constants);
@@ -90,19 +86,16 @@ public class ConstantExtractor {
String methodName = mi.getName().getIdentifier();
log.trace("Processing mi: {}", mi);
if (methodName.equals("get") || methodName.equals("getOrDefault")) {
if (mi.getExpression() != null) {
if (variableTracer != null) {
List<Expression> traced = variableTracer.traceVariableAll(mi.getExpression());
for (Expression t : traced) {
extractConstantsFromExpression(t, constants);
}
} else {
extractConstantsFromExpression(mi.getExpression(), constants);
}
}
if (("get".equals(methodName) || "getOrDefault".equals(methodName)) && mi.getExpression() != null) {
if (isKeyedLookupMethod(mi)) {
extractMapReceiverConstants(mi.getExpression(), constants);
return;
}
if ("get".equals(methodName) && mi.arguments().isEmpty()) {
extractMapReceiverConstants(mi.getExpression(), constants);
return;
}
}
if (methodName.equals("of") || methodName.equals("ofEntries") || methodName.equals("asList") || methodName.equals("entry") ||
methodName.equals("just") || methodName.equals("withPayload") || methodName.equals("success")) {
for (Object argObj : mi.arguments()) {
@@ -402,6 +395,84 @@ public class ConstantExtractor {
return eVal.contains(".") ? eVal.substring(eVal.lastIndexOf('.', eVal.lastIndexOf('.') - 1) + 1) : eVal;
}
private boolean isKeyedLookupMethod(MethodInvocation mi) {
if (expressionAccessClassifier != null) {
return expressionAccessClassifier.isKeyedLookup(mi, null);
}
return "getOrDefault".equals(mi.getName().getIdentifier())
|| ("get".equals(mi.getName().getIdentifier()) && !mi.arguments().isEmpty());
}
/**
* Resolves constants from the map/collection receiver of {@code .get(key)}.
* Uses {@link ConstantResolver} for static fields ({@code ROUTES = Map.of(...)}) first;
* falls back to dataflow tracing for locals ({@code Map map = ...; map.get(k)}).
*/
private void extractMapReceiverConstants(Expression receiver, List<String> constants) {
if (tryAddResolvedExpression(receiver, constants)) {
return;
}
if (receiver instanceof MethodInvocation || receiver instanceof ArrayCreation || receiver instanceof ArrayInitializer) {
extractConstantsFromExpression(receiver, constants);
if (!constants.isEmpty()) {
return;
}
}
if (variableTracer == null) {
return;
}
String receiverText = receiver.toString();
for (Expression traced : variableTracer.traceVariableAll(receiver)) {
if (traced == null || receiverText.equals(traced.toString())) {
continue;
}
if (tryAddResolvedExpression(traced, constants)) {
continue;
}
extractConstantsFromExpression(traced, constants);
}
}
private boolean tryAddResolvedExpression(Expression expr, List<String> constants) {
if (constantResolver == null) {
return false;
}
String resolved = constantResolver.resolve(expr, context);
if (resolved == null) {
return false;
}
addResolvedConstant(resolved, constants);
return true;
}
private void addResolvedConstant(String resolved, List<String> constants) {
if (resolved.startsWith("ENUM_SET:")) {
for (String eVal : resolved.substring(9).split(",")) {
String parsed = parseEnumSetElement(eVal);
if (!constants.contains(parsed)) {
constants.add(parsed);
}
}
} else if (resolved.startsWith("MAP:")) {
List<String> mapValues = ConstantResolver.decodeMapLiteralValues(resolved);
if (mapValues != null) {
for (String mapValue : mapValues) {
if (!constants.contains(mapValue)) {
constants.add(mapValue);
}
}
}
} else if (resolved.startsWith("ARRAY:")) {
for (String arrayValue : resolved.substring(6).split("\\|", -1)) {
if (!constants.contains(arrayValue)) {
constants.add(arrayValue);
}
}
} else if (!constants.contains(resolved)) {
constants.add(resolved);
}
}
private Block findEnclosingBlock(ASTNode node) {
ASTNode parent = node.getParent();
while (parent != null && !(parent instanceof Block)) {

View File

@@ -0,0 +1,271 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.*;
import java.util.HashSet;
import java.util.Set;
import java.util.function.Function;
/**
* Classifies trailing expression access (map lookup vs traceable accessor) using AST structure and
* declared receiver types instead of string heuristics.
*/
public final class ExpressionAccessClassifier {
public enum AccessKind {
/** {@code Map.get(key)}, {@code list.get(i)} — keep intact for {@link ConstantExtractor}. */
KEYED_LOOKUP,
/** {@code getEvent()}, {@code supplier.get()} — split receiver/suffix for call-path tracing. */
TRACEABLE_ACCESSOR,
NONE
}
private final CodebaseContext context;
private final VariableTracer variableTracer;
private final ConstantResolver constantResolver;
private final Function<String, ASTNode> expressionParser;
public ExpressionAccessClassifier(
CodebaseContext context,
VariableTracer variableTracer,
ConstantResolver constantResolver,
Function<String, ASTNode> expressionParser) {
this.context = context;
this.variableTracer = variableTracer;
this.constantResolver = constantResolver;
this.expressionParser = expressionParser;
}
public AccessKind classifyTrailingAccess(String expression, String scopeMethod) {
if (expression == null || expression.isBlank()) {
return AccessKind.NONE;
}
ASTNode node = expressionParser.apply(expression);
while (node instanceof ParenthesizedExpression pe) {
node = pe.getExpression();
}
if (node instanceof ArrayAccess) {
return AccessKind.NONE;
}
if (node instanceof MethodInvocation mi) {
return classifyMethodInvocation(mi, scopeMethod);
}
return AccessKind.NONE;
}
public AccessKind classifyMethodInvocation(MethodInvocation mi, String scopeMethod) {
if (mi == null) {
return AccessKind.NONE;
}
String methodName = mi.getName().getIdentifier();
int argCount = mi.arguments().size();
if ("getOrDefault".equals(methodName) && argCount >= 1) {
return AccessKind.KEYED_LOOKUP;
}
if ("get".equals(methodName)) {
String receiverType = resolveReceiverType(mi.getExpression(), scopeMethod);
if (argCount == 0) {
if (isMapLikeType(receiverType)) {
return AccessKind.KEYED_LOOKUP;
}
return AccessKind.TRACEABLE_ACCESSOR;
}
if (isMapLikeType(receiverType) || isIndexedCollectionType(receiverType)) {
return AccessKind.KEYED_LOOKUP;
}
if (receiverExpressionIsMapLiteral(mi.getExpression())) {
return AccessKind.KEYED_LOOKUP;
}
return AccessKind.TRACEABLE_ACCESSOR;
}
if (argCount == 0 && (isBeanAccessorName(methodName) || isRecordStyleAccessor(methodName))) {
return AccessKind.TRACEABLE_ACCESSOR;
}
return AccessKind.TRACEABLE_ACCESSOR;
}
public boolean isKeyedLookup(String expression, String scopeMethod) {
return classifyTrailingAccess(expression, scopeMethod) == AccessKind.KEYED_LOOKUP;
}
public boolean isKeyedLookup(MethodInvocation mi, String scopeMethod) {
return classifyMethodInvocation(mi, scopeMethod) == AccessKind.KEYED_LOOKUP;
}
private boolean receiverExpressionIsMapLiteral(Expression receiver) {
if (constantResolver == null || context == null || receiver == null) {
return false;
}
String resolved = constantResolver.resolve(receiver, context);
return resolved != null && resolved.startsWith("MAP:");
}
private String resolveReceiverType(Expression receiver, String scopeMethod) {
if (receiver == null) {
return null;
}
if (receiverExpressionIsMapLiteral(receiver)) {
return "Map";
}
if (receiver instanceof SimpleName sn) {
return lookupDeclaredType(scopeMethod, sn.getIdentifier());
}
if (receiver instanceof ThisExpression) {
return classNameFromScopeMethod(scopeMethod);
}
if (receiver instanceof FieldAccess fa) {
String fieldName = fa.getName().getIdentifier();
if (fa.getExpression() instanceof ThisExpression) {
return lookupFieldType(classNameFromScopeMethod(scopeMethod), fieldName);
}
if (fa.getExpression() instanceof SimpleName outer) {
String outerType = lookupDeclaredType(scopeMethod, outer.getIdentifier());
return lookupFieldType(rawTypeName(outerType), fieldName);
}
if (fa.getExpression() instanceof QualifiedName qn) {
String outerType = resolveQualifiedTypeName(qn);
return lookupFieldType(outerType, fieldName);
}
}
if (receiver instanceof QualifiedName qn) {
String typeName = resolveQualifiedTypeName(qn);
if (typeName != null) {
String fieldName = qn.getName().getIdentifier();
String fieldType = lookupFieldType(typeName, fieldName);
if (fieldType != null) {
return fieldType;
}
}
}
if (receiver instanceof MethodInvocation chainMi) {
AccessKind kind = classifyMethodInvocation(chainMi, scopeMethod);
if (kind == AccessKind.TRACEABLE_ACCESSOR) {
String getterName = chainMi.getName().getIdentifier();
String baseType = resolveReceiverType(chainMi.getExpression(), scopeMethod);
return lookupMethodReturnType(rawTypeName(baseType), getterName);
}
}
return null;
}
private String lookupDeclaredType(String scopeMethod, String name) {
if (variableTracer == null || scopeMethod == null || name == null) {
return null;
}
return variableTracer.getVariableDeclaredType(scopeMethod, name);
}
private String lookupFieldType(String typeName, String fieldName) {
if (context == null || typeName == null || fieldName == null) {
return null;
}
TypeDeclaration td = context.getTypeDeclaration(typeName);
if (td == null) {
return null;
}
return findFieldType(td, fieldName, new HashSet<>());
}
private String findFieldType(TypeDeclaration td, String fieldName, Set<String> visited) {
String typeFqn = context.getFqn(td);
if (typeFqn == null || !visited.add(typeFqn)) {
return null;
}
for (FieldDeclaration fd : td.getFields()) {
for (Object fragObj : fd.fragments()) {
VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj;
if (frag.getName().getIdentifier().equals(fieldName)) {
return fd.getType().toString();
}
}
}
String superFqn = context.getSuperclassFqn(td);
if (superFqn != null) {
TypeDeclaration superTd = context.getTypeDeclaration(superFqn);
if (superTd != null) {
String inherited = findFieldType(superTd, fieldName, visited);
if (inherited != null) {
return inherited;
}
}
}
return null;
}
private String lookupMethodReturnType(String typeName, String methodName) {
if (context == null || typeName == null || methodName == null) {
return null;
}
TypeDeclaration td = context.getTypeDeclaration(typeName);
if (td == null) {
return null;
}
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md != null && md.getReturnType2() != null) {
return md.getReturnType2().toString();
}
return null;
}
private String resolveQualifiedTypeName(QualifiedName qn) {
String full = qn.getFullyQualifiedName();
if (full.contains(".")) {
return full.substring(0, full.lastIndexOf('.'));
}
return classNameFromScopeMethod(null);
}
private static String classNameFromScopeMethod(String scopeMethod) {
if (scopeMethod == null || !scopeMethod.contains(".")) {
return null;
}
return scopeMethod.substring(0, scopeMethod.lastIndexOf('.'));
}
private static boolean isBeanAccessorName(String methodName) {
return (methodName.startsWith("get") && methodName.length() > 3)
|| (methodName.startsWith("is") && methodName.length() > 2);
}
private static boolean isRecordStyleAccessor(String methodName) {
return "type".equals(methodName) || "event".equals(methodName) || "name".equals(methodName);
}
static boolean isMapLikeType(String type) {
String raw = rawTypeName(type);
if (raw == null) {
return false;
}
return "Map".equals(raw) || raw.endsWith("Map");
}
static boolean isIndexedCollectionType(String type) {
String raw = rawTypeName(type);
if (raw == null) {
return false;
}
return "List".equals(raw) || raw.endsWith("List")
|| "Set".equals(raw) || raw.endsWith("Set")
|| raw.endsWith("Deque") || "Queue".equals(raw);
}
static String rawTypeName(String type) {
if (type == null) {
return null;
}
String raw = type.trim();
if (raw.contains("<")) {
raw = raw.substring(0, raw.indexOf('<'));
}
if (raw.contains(".")) {
raw = raw.substring(raw.lastIndexOf('.') + 1);
}
return raw;
}
}

View File

@@ -18,6 +18,10 @@ public class JdtDataFlowModel implements DataFlowModel {
CURRENT_PATH.set(path);
}
public static List<String> getCurrentPath() {
return CURRENT_PATH.get();
}
public static void clearCurrentPath() {
CURRENT_PATH.remove();
}

View File

@@ -57,8 +57,8 @@ public class ExporterCommand implements Callable<Integer> {
@Option(names = {"--debug"}, description = "Enable diagnostic mode to show unresolved call chains and detailed analysis logging.", defaultValue = "false")
private boolean debug;
@Option(names = {"--resolve-classpath"}, description = "Automatically run Maven/Gradle to resolve full classpath dependencies for perfect AST bindings.", defaultValue = "false")
private boolean resolveClasspath;
@Option(names = {"--resolve-classpath"}, description = "Automatically run Maven/Gradle to resolve full classpath dependencies for perfect AST bindings.", defaultValue = "true", fallbackValue = "true")
private boolean resolveClasspath = true;
@Override
public Integer call() throws Exception {

View File

@@ -17,6 +17,57 @@ class TransitionLinkerEnricherTest {
private final TransitionLinkerEnricher enricher = new TransitionLinkerEnricher();
@Test
void shouldLinkWhenPolymorphicEventsContainConcreteEnumConstant() {
Transition payT = new Transition();
payT.setSourceStates(List.of(State.of("NEW", "OrderState.NEW")));
payT.setTargetStates(List.of(State.of("PAID", "OrderState.PAID")));
payT.setEvent(Event.of("OrderEvent.PAY", "com.example.OrderEvent.PAY"));
CallChain chain = CallChain.builder()
.triggerPoint(TriggerPoint.builder()
.event("e")
.polymorphicEvents(List.of("OrderEvent.PAY"))
.build())
.build();
AnalysisResult result = AnalysisResult.builder()
.name("OrderStateMachineConfig")
.transitions(List.of(payT))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
.build();
enricher.enrich(result, null, null);
assertThat(result.getMetadata().getCallChains().get(0).getMatchedTransitions()).hasSize(1);
}
@Test
void shouldRespectDispatcherBranchConstraintForMachineDomain() {
Transition payT = new Transition();
payT.setSourceStates(List.of(State.of("NEW", "OrderState.NEW")));
payT.setTargetStates(List.of(State.of("PAID", "OrderState.PAID")));
payT.setEvent(Event.of("OrderEvent.PAY", "com.example.OrderEvent.PAY"));
CallChain chain = CallChain.builder()
.triggerPoint(TriggerPoint.builder()
.event("OrderEvent.PAY")
.polymorphicEvents(List.of("OrderEvent.PAY"))
.constraint("\"ORDER\".equalsIgnoreCase(type)")
.build())
.build();
AnalysisResult result = AnalysisResult.builder()
.name("com.example.OrderStateMachineConfig")
.transitions(List.of(payT))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
.build();
enricher.enrich(result, null, null);
assertThat(result.getMetadata().getCallChains().get(0).getMatchedTransitions()).hasSize(1);
}
@Test
void shouldLinkTransitionByEventOnly() {
Transition t1 = new Transition();
@@ -543,4 +594,30 @@ class TransitionLinkerEnricherTest {
enricher.enrich(resultUser, null, null);
assertThat(resultUser.getMetadata().getCallChains().get(0).getMatchedTransitions()).isNullOrEmpty();
}
@Test
void shouldRejectChainWhenDispatcherDomainConstraintDoesNotMatchMachine() {
Transition payT = new Transition();
payT.setSourceStates(List.of(State.of("NEW", "OrderState.NEW")));
payT.setTargetStates(List.of(State.of("PAID", "OrderState.PAID")));
payT.setEvent(Event.of("OrderEvent.PAY", "com.example.OrderEvent.PAY"));
CallChain chain = CallChain.builder()
.triggerPoint(TriggerPoint.builder()
.event("OrderEvent.PAY")
.polymorphicEvents(List.of("OrderEvent.PAY"))
.constraint("\"PAYMENT\".equalsIgnoreCase(type)")
.build())
.build();
AnalysisResult result = AnalysisResult.builder()
.name("com.example.OrderStateMachineConfig")
.transitions(List.of(payT))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
.build();
enricher.enrich(result, null, null);
assertThat(result.getMetadata().getCallChains().get(0).getMatchedTransitions()).isNullOrEmpty();
}
}

View File

@@ -142,14 +142,37 @@ class StrictFqnMatchingEngineTest {
}
@Test
void shouldMatchValueOfWithCorrectEnum() {
void shouldMatchValueOfWithCorrectEnumWhenPolymorphicEventIsResolved() {
Event smEvent = Event.of("PAY", "com.example.OrderEvents.PAY");
TriggerPoint triggerPoint = TriggerPoint.builder()
.event("OrderEvents.valueOf(str)")
.eventTypeFqn("com.example.OrderEvents")
.polymorphicEvents(List.of("OrderEvents.PAY"))
.build();
assertThat(engine.matches(smEvent, triggerPoint)).isTrue();
}
@Test
void shouldNotMatchUnresolvedValueOfWithoutPolymorphicEvents() {
Event smEvent = Event.of("PAY", "com.example.OrderEvents.PAY");
TriggerPoint triggerPoint = TriggerPoint.builder()
.event("OrderEvents.valueOf(str)")
.eventTypeFqn("com.example.OrderEvents")
.build();
assertThat(engine.matches(smEvent, triggerPoint)).isTrue();
assertThat(engine.matches(smEvent, triggerPoint)).isFalse();
}
@Test
void shouldNotMatchExternalTriggerWithoutPolymorphicEvents() {
Event smEvent = Event.of("PAY", "com.example.OrderEvents.PAY");
TriggerPoint triggerPoint = TriggerPoint.builder()
.event("event")
.external(true)
.build();
assertThat(engine.matches(smEvent, triggerPoint)).isFalse();
}
@Test

View File

@@ -0,0 +1,66 @@
package click.kamil.springstatemachineexporter.analysis.resolver;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class BooleanConstraintEvaluatorTest {
@Test
void shouldEvaluateLiteralEqualsAgainstMachineDomain() {
assertThat(BooleanConstraintEvaluator.isCompatibleWithMachineDomain(
"\"ORDER\".equals(domain)", "OrderStateMachineConfiguration")).isTrue();
assertThat(BooleanConstraintEvaluator.isCompatibleWithMachineDomain(
"\"PAYMENT\".equals(domain)", "OrderStateMachineConfiguration")).isFalse();
}
@Test
void shouldEvaluateReverseEqualsAndInequality() {
assertThat(BooleanConstraintEvaluator.isCompatibleWithMachineDomain(
"domain.equals(\"ORDER\")", "OrderStateMachineConfiguration")).isTrue();
assertThat(BooleanConstraintEvaluator.isCompatibleWithMachineDomain(
"domain != \"ORDER\"", "OrderStateMachineConfiguration")).isFalse();
}
@Test
void shouldEvaluateStringEqualsForKnownLiterals() {
assertThat(BooleanConstraintEvaluator.evaluateStringEquals("ORDER", "order", true)).isTrue();
assertThat(BooleanConstraintEvaluator.evaluateStringEquals("ORDER", "order", false)).isFalse();
assertThat(BooleanConstraintEvaluator.evaluateStringEquals(null, "ORDER", true)).isNull();
}
@Test
void shouldEvaluateCombinedBooleanLogic() {
assertThat(BooleanConstraintEvaluator.isCompatibleWithMachineDomain(
"\"ORDER\".equals(type) && \"ORDER\".equals(domain)", "OrderStateMachineConfiguration")).isTrue();
assertThat(BooleanConstraintEvaluator.isCompatibleWithMachineDomain(
"\"ORDER\".equals(type) && \"PAYMENT\".equals(domain)", "OrderStateMachineConfiguration")).isFalse();
assertThat(BooleanConstraintEvaluator.isCompatibleWithMachineDomain(
"\"PAYMENT\".equals(type) || \"ORDER\".equals(type)", "OrderStateMachineConfiguration")).isTrue();
assertThat(BooleanConstraintEvaluator.isCompatibleWithMachineDomain(
"\"PAYMENT\".equals(type) || \"DOCUMENT\".equals(type)", "OrderStateMachineConfiguration")).isFalse();
}
@Test
void shouldEvaluateEqualsIgnoreCaseAgainstMachineDomain() {
assertThat(BooleanConstraintEvaluator.isCompatibleWithMachineDomain(
"\"order\".equalsIgnoreCase(type)", "OrderStateMachineConfiguration")).isTrue();
assertThat(BooleanConstraintEvaluator.isCompatibleWithMachineDomain(
"\"payment\".equalsIgnoreCase(type)", "OrderStateMachineConfiguration")).isFalse();
}
@Test
void shouldEvaluateNegatedDomainChecks() {
assertThat(BooleanConstraintEvaluator.isCompatibleWithMachineDomain(
"!(\"PAYMENT\".equals(type))", "OrderStateMachineConfiguration")).isTrue();
assertThat(BooleanConstraintEvaluator.isCompatibleWithMachineDomain(
"!(\"ORDER\".equals(type))", "OrderStateMachineConfiguration")).isFalse();
}
@Test
void evaluateBooleanExpressionShouldHandleLogicalOperators() {
assertThat(BooleanConstraintEvaluator.evaluateBooleanExpression("true && false")).isFalse();
assertThat(BooleanConstraintEvaluator.evaluateBooleanExpression("true || false")).isTrue();
assertThat(BooleanConstraintEvaluator.evaluateBooleanExpression("!(false)")).isTrue();
}
}

View File

@@ -1,5 +1,6 @@
package click.kamil.springstatemachineexporter.analysis.resolver;
import click.kamil.springstatemachineexporter.analysis.service.ConstantExtractor;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.*;
import org.junit.jupiter.api.Test;
@@ -8,6 +9,8 @@ import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@@ -524,4 +527,166 @@ public class ConstantResolverTest {
String result2 = resolver.resolve(mi2, context);
assertThat(result2).isEqualTo("com.example.OrderEvent.SHIPPED");
}
@Test
void shouldResolveMapOfGetWithLiteralKey(@TempDir Path tempDir) throws IOException {
Path dir = tempDir.resolve("com/example");
Files.createDirectories(dir);
Files.writeString(dir.resolve("Routes.java"),
"package com.example;\n" +
"public class Routes {\n" +
" public static final String PAY = \"OrderEvents.PAY\";\n" +
" public static final String SHIP = \"OrderEvents.SHIP\";\n" +
" public String lookup() {\n" +
" return java.util.Map.of(\"order.pay\", PAY, \"order.ship\", SHIP).get(\"order.pay\");\n" +
" }\n" +
"}");
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
TypeDeclaration td = context.getTypeDeclaration("com.example.Routes");
MethodDeclaration lookup = td.getMethods()[0];
ReturnStatement rs = (ReturnStatement) lookup.getBody().statements().get(0);
ConstantResolver resolver = new ConstantResolver();
String result = resolver.resolve(rs.getExpression(), context);
assertThat(result).isEqualTo("OrderEvents.PAY");
}
@Test
void shouldResolveStaticMapFieldGetWithLiteralKey(@TempDir Path tempDir) throws IOException {
Path dir = tempDir.resolve("com/example");
Files.createDirectories(dir);
Files.writeString(dir.resolve("Dispatcher.java"),
"package com.example;\n" +
"import java.util.Map;\n" +
"public class Dispatcher {\n" +
" private static final Map<String, String> ROUTES = Map.of(\n" +
" \"order.pay\", \"OrderEvents.PAY\",\n" +
" \"order.ship\", \"OrderEvents.SHIP\"\n" +
" );\n" +
" public String route() {\n" +
" return ROUTES.get(\"order.ship\");\n" +
" }\n" +
"}");
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
TypeDeclaration td = context.getTypeDeclaration("com.example.Dispatcher");
MethodDeclaration route = td.getMethods()[0];
ReturnStatement rs = (ReturnStatement) route.getBody().statements().get(0);
ConstantResolver resolver = new ConstantResolver();
String result = resolver.resolve(rs.getExpression(), context);
assertThat(result).isEqualTo("OrderEvents.SHIP");
}
@Test
void shouldResolveLiteralStringTransforms(@TempDir Path tempDir) throws IOException {
Path dir = tempDir.resolve("com/example");
Files.createDirectories(dir);
Files.writeString(dir.resolve("OrderEvent.java"),
"package com.example;\n" +
"public enum OrderEvent { PAY, SHIP }\n");
Files.writeString(dir.resolve("Caller.java"),
"package com.example;\n" +
"public class Caller {\n" +
" public OrderEvent fromLiteral() {\n" +
" return OrderEvent.valueOf(\"pay\".toUpperCase());\n" +
" }\n" +
" public String trimmed() {\n" +
" return \" pay \".trim().toUpperCase();\n" +
" }\n" +
"}");
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
TypeDeclaration td = context.getTypeDeclaration("com.example.Caller");
MethodDeclaration fromLiteral = td.getMethods()[0];
MethodDeclaration trimmed = td.getMethods()[1];
ReturnStatement rs1 = (ReturnStatement) fromLiteral.getBody().statements().get(0);
ReturnStatement rs2 = (ReturnStatement) trimmed.getBody().statements().get(0);
ConstantResolver resolver = new ConstantResolver();
assertThat(resolver.resolve(rs1.getExpression(), context)).isEqualTo("com.example.OrderEvent.PAY");
assertThat(resolver.resolve(rs2.getExpression(), context)).isEqualTo("PAY");
}
@Test
void shouldResolveStaticFieldUsingCallPathScope(@TempDir Path tempDir) throws IOException {
Path dir = tempDir.resolve("com/example");
Files.createDirectories(dir);
Files.writeString(dir.resolve("ApiController.java"),
"package com.example;\n" +
"import java.util.Map;\n" +
"public class ApiController {\n" +
" private static final Map<String, OrderEvent> ROUTES = Map.of(\n" +
" \"order.pay\", OrderEvent.PAY,\n" +
" \"order.ship\", OrderEvent.SHIP\n" +
" );\n" +
" public void dispatch(String commandKey) {\n" +
" OrderEvent event = ROUTES.get(commandKey);\n" +
" }\n" +
"}\n" +
"enum OrderEvent { PAY, SHIP }\n");
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
ASTParser parser = ASTParser.newParser(AST.JLS17);
parser.setKind(ASTParser.K_COMPILATION_UNIT);
parser.setSource(("class A { Object o = ROUTES.get(commandKey); }").toCharArray());
CompilationUnit cu = (CompilationUnit) parser.createAST(null);
FieldDeclaration fd = (FieldDeclaration) ((TypeDeclaration) cu.types().get(0)).bodyDeclarations().get(0);
MethodInvocation getCall = (MethodInvocation) ((VariableDeclarationFragment) fd.fragments().get(0)).getInitializer();
ConstantExtractor extractor = new ConstantExtractor(context, new ConstantResolver(), mi -> null);
click.kamil.springstatemachineexporter.ast.common.JdtDataFlowModel.setCurrentPath(
List.of("com.example.ApiController.dispatch"));
try {
ConstantResolver resolver = new ConstantResolver();
SimpleName routesName = (SimpleName) getCall.getExpression();
assertThat(resolver.resolve(routesName, context))
.as("static ROUTES field should resolve to encoded map")
.startsWith("MAP:");
List<String> constants = new ArrayList<>();
extractor.extractConstantsFromExpression(getCall, constants);
assertThat(constants).contains("OrderEvent.PAY", "OrderEvent.SHIP");
} finally {
click.kamil.springstatemachineexporter.ast.common.JdtDataFlowModel.clearCurrentPath();
}
}
@Test
void shouldResolveArrayIndexWithLiteralMath(@TempDir Path tempDir) throws IOException {
Path dir = tempDir.resolve("com/example");
Files.createDirectories(dir);
Files.writeString(dir.resolve("Table.java"),
"package com.example;\n" +
"public class Table {\n" +
" private static final String[] EVENTS = {\"OrderEvents.PAY\", \"OrderEvents.SHIP\", \"OrderEvents.CANCEL\"};\n" +
" public String pick() {\n" +
" return EVENTS[1 + 1];\n" +
" }\n" +
"}");
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
TypeDeclaration td = context.getTypeDeclaration("com.example.Table");
MethodDeclaration pick = td.getMethods()[0];
ReturnStatement rs = (ReturnStatement) pick.getBody().statements().get(0);
ConstantResolver resolver = new ConstantResolver();
String result = resolver.resolve(rs.getExpression(), context);
assertThat(result).isEqualTo("OrderEvents.CANCEL");
}
}

View File

@@ -0,0 +1,339 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.enricher.CallChainEnricher;
import click.kamil.springstatemachineexporter.analysis.enricher.EntryPointEnricher;
import click.kamil.springstatemachineexporter.analysis.enricher.TransitionLinkerEnricher;
import click.kamil.springstatemachineexporter.analysis.enricher.TriggerEnricher;
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.ast.app.StateMachineAggregator;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.model.Transition;
import org.eclipse.jdt.core.dom.TypeDeclaration;
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.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Guards the shared analysis caches used when one codebase produces many inherited state machines.
*
* <p>Typical repo shape: one abstract config, several abstract branches, many concrete configs.
* The exporter scans once, caches the call graph, then enriches each machine from the same context.
*/
class AnalysisCacheCorrectnessTest {
private static Path findProjectRoot() {
Path current = Path.of(".").toAbsolutePath();
while (current != null && !Files.exists(current.resolve("settings.gradle"))) {
current = current.getParent();
}
return current;
}
/**
* The call graph is expensive to build; it must be stored on {@link CodebaseContext} and reused
* by subsequent engines pointing at the same scan result.
*/
@Test
void heuristicCallGraphShouldBeCachedOnContext(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
class A { void go() { new B().run("PAY"); } }
class B { void run(String s) { new C().fire(com.example.E.PAY); } }
enum E { PAY, SHIP }
class C { void fire(E e) {} }
""";
Files.writeString(tempDir.resolve("App.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
HeuristicCallGraphEngine firstEngine = new HeuristicCallGraphEngine(context);
firstEngine.findChains(
List.of(EntryPoint.builder().className("com.example.A").methodName("go").build()),
List.of(TriggerPoint.builder().className("com.example.C").methodName("fire").event("e").build()));
assertThat(context.getCache()).containsKey("heuristicCallGraph");
Object cachedGraph = context.getCache().get("heuristicCallGraph");
HeuristicCallGraphEngine secondEngine = new HeuristicCallGraphEngine(context);
secondEngine.findChains(
List.of(EntryPoint.builder().className("com.example.A").methodName("go").build()),
List.of(TriggerPoint.builder().className("com.example.C").methodName("fire").event("e").build()));
assertThat(context.getCache().get("heuristicCallGraph")).isSameAs(cachedGraph);
}
/**
* Two consecutive {@link CallGraphEngine#findChains} calls on a cached graph must agree.
*/
@Test
void repeatedCallChainResolutionShouldBeDeterministic(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
class Controller {
Dispatcher dispatcher;
public void pay() { dispatcher.dispatch("ORDER", "PAY"); }
}
class Dispatcher {
public void dispatch(String type, String action) {
StateMachine sm = new StateMachine();
if ("ORDER".equals(type)) {
OrderEvent ev = OrderEvent.valueOf(action);
sm.sendEvent(ev);
}
}
}
enum OrderEvent { PAY, SHIP }
class StateMachine { void sendEvent(OrderEvent e) {} }
""";
Files.writeString(tempDir.resolve("App.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
EntryPoint pay = EntryPoint.builder().className("com.example.Controller").methodName("pay").build();
TriggerPoint trigger = TriggerPoint.builder().className("com.example.StateMachine").methodName("sendEvent").event("e").build();
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
CallChain first = engine.findChains(List.of(pay), List.of(trigger)).get(0);
CallChain second = engine.findChains(List.of(pay), List.of(trigger)).get(0);
assertThat(second.getTriggerPoint().getPolymorphicEvents())
.isEqualTo(first.getTriggerPoint().getPolymorphicEvents());
assertThat(second.getMethodChain()).isEqualTo(first.getMethodChain());
}
/**
* A single {@link TransitionLinkerEnricher} instance is reused across machines during export.
* Its internal memoization must not let machine B overwrite machine A's matched transitions.
*/
@Test
void sharedTransitionLinkerShouldNotCrossContaminateMachineResults(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
class OrderController {
SharedBus bus;
void pay() { bus.orderPay(); }
}
class ShipmentController {
SharedBus bus;
void go() { bus.shipmentDispatch(); }
}
class SharedBus {
public void orderPay() {
OrderMachine sm = new OrderMachine();
sm.sendEvent(OrderEvent.PAY);
}
public void shipmentDispatch() {
ShipmentMachine sm = new ShipmentMachine();
sm.sendEvent(ShipmentEvent.DISPATCH);
}
}
enum OrderEvent { PAY }
enum ShipmentEvent { DISPATCH }
class OrderMachine { void sendEvent(OrderEvent e) {} }
class ShipmentMachine { void sendEvent(ShipmentEvent e) {} }
""";
Files.writeString(tempDir.resolve("App.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
CallChain orderChain = engine.findChains(
List.of(EntryPoint.builder().className("com.example.OrderController").methodName("pay").build()),
List.of(TriggerPoint.builder().className("com.example.OrderMachine").methodName("sendEvent").event("e").build())
).get(0);
CallChain shipmentChain = engine.findChains(
List.of(EntryPoint.builder().className("com.example.ShipmentController").methodName("go").build()),
List.of(TriggerPoint.builder().className("com.example.ShipmentMachine").methodName("sendEvent").event("e").build())
).get(0);
TransitionLinkerEnricher sharedLinker = new TransitionLinkerEnricher();
AnalysisResult orderMachine = analysisWithChain(
"com.example.OrderStateMachineConfig",
orderChain,
CentralDispatcherTestSupport.transition("NEW", "PAID", "OrderEvent.PAY"));
AnalysisResult shipmentMachine = analysisWithChain(
"com.example.ShipmentStateMachineConfig",
shipmentChain,
CentralDispatcherTestSupport.transition("READY", "IN_TRANSIT", "ShipmentEvent.DISPATCH"));
sharedLinker.enrich(orderMachine, null, null);
sharedLinker.enrich(shipmentMachine, null, null);
assertThat(orderMachine.getMetadata().getCallChains().get(0).getMatchedTransitions())
.extracting("event")
.containsExactly("OrderEvent.PAY");
assertThat(shipmentMachine.getMetadata().getCallChains().get(0).getMatchedTransitions())
.extracting("event")
.containsExactly("ShipmentEvent.DISPATCH");
// Re-enrich order machine after shipment machine was processed; result must stay stable.
sharedLinker.enrich(orderMachine, null, null);
assertThat(orderMachine.getMetadata().getCallChains().get(0).getMatchedTransitions())
.extracting("event")
.containsExactly("OrderEvent.PAY");
}
/**
* Exercises a real inherited hierarchy (F1/F2/G1/G2) from one scan + one intelligence provider,
* mirroring repos where many concrete configs extend shared abstract layers.
*/
@Test
void inheritedHierarchyShouldProduceStableDistinctMachineSnapshots() throws Exception {
Path sampleRoot = findProjectRoot().resolve("state_machines/inheritance_extra_functions3_state_machine");
CodebaseContext context = new CodebaseContext();
context.setProjectRoot(sampleRoot);
context.setSourcepath(List.of(sampleRoot.resolve("src/main/java").toString()));
context.setResolveBindings(true);
context.scan(sampleRoot);
JdtIntelligenceProvider intelligence = new JdtIntelligenceProvider(context, sampleRoot);
EnrichmentService enrichmentService = new EnrichmentService(List.of(
new TriggerEnricher(),
new EntryPointEnricher(),
new CallChainEnricher(),
new TransitionLinkerEnricher()));
List<String> configNames = List.of(
"F1StateMachineConfiguration",
"F2StateMachineConfiguration",
"G1StateMachineConfiguration",
"G2StateMachineConfiguration");
Map<String, Snapshot> firstPass = enrichAllConfigs(context, intelligence, enrichmentService, configNames);
Map<String, Snapshot> secondPass = enrichAllConfigs(context, intelligence, enrichmentService, configNames);
assertThat(secondPass).isEqualTo(firstPass);
assertThat(firstPass).hasSize(4);
List<Snapshot> snapshots = new ArrayList<>(firstPass.values());
assertThat(snapshots.get(0).transitionCount()).isNotEqualTo(snapshots.get(2).transitionCount());
assertThat(snapshots.get(0).eventNames()).isNotEqualTo(snapshots.get(2).eventNames());
}
/**
* Synthetic repo shape: 1 root abstract → 3 branch abstracts → 4 concrete configs each (12 machines).
* One scan and one intelligence provider must yield stable, distinct snapshots on repeat enrichment.
*
* <pre>
* RootAbstractSmConfig
* ├── BranchAAbstractSmConfig → BranchA1..A4SmConfig
* ├── BranchBAbstractSmConfig → BranchB1..B4SmConfig
* └── BranchCAbstractSmConfig → BranchC1..C4SmConfig
* </pre>
*/
@Test
void twelveConfigSyntheticHierarchyShouldProduceStableDistinctSnapshots(@TempDir Path tempDir) throws Exception {
TwelveConfigHierarchyTestSupport.writeSources(tempDir);
CodebaseContext context = new CodebaseContext();
context.setProjectRoot(tempDir);
context.setResolveBindings(true);
context.scan(tempDir);
JdtIntelligenceProvider intelligence = new JdtIntelligenceProvider(context, tempDir);
EnrichmentService enrichmentService = new EnrichmentService(List.of(
new TriggerEnricher(),
new EntryPointEnricher(),
new CallChainEnricher(),
new TransitionLinkerEnricher()));
List<String> configNames = TwelveConfigHierarchyTestSupport.concreteConfigNames();
Map<String, Snapshot> firstPass = enrichAllConfigs(context, intelligence, enrichmentService, configNames);
Object cachedGraphAfterFirstPass = context.getCache().get("jdtCallGraph");
Map<String, Snapshot> secondPass = enrichAllConfigs(context, intelligence, enrichmentService, configNames);
assertThat(firstPass).hasSize(12);
assertThat(secondPass).isEqualTo(firstPass);
assertThat(new ArrayList<>(firstPass.values())).doesNotHaveDuplicates();
// Each concrete config inherits root + branch + its own transition.
assertThat(firstPass.get("com.example.hierarchy.BranchA1SmConfig").transitionCount()).isEqualTo(3);
assertThat(firstPass.get("com.example.hierarchy.BranchA1SmConfig").eventNames())
.containsExactlyInAnyOrderElementsOf(TwelveConfigHierarchyTestSupport.expectedEventsForConcrete("A", 1));
assertThat(firstPass.get("com.example.hierarchy.BranchC4SmConfig").eventNames())
.containsExactlyInAnyOrderElementsOf(TwelveConfigHierarchyTestSupport.expectedEventsForConcrete("C", 4));
// Branch A and branch C machines must differ even when they share the same index.
assertThat(firstPass.get("com.example.hierarchy.BranchA2SmConfig"))
.isNotEqualTo(firstPass.get("com.example.hierarchy.BranchC2SmConfig"));
// Call-graph cache must survive enriching all twelve configs (same graph instance on second pass).
if (cachedGraphAfterFirstPass != null) {
assertThat(context.getCache().get("jdtCallGraph")).isSameAs(cachedGraphAfterFirstPass);
}
}
private static AnalysisResult analysisWithChain(String machineName, CallChain chain, Transition transition) {
return AnalysisResult.builder()
.name(machineName)
.transitions(List.of(transition))
.metadata(click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata.builder()
.callChains(List.of(chain))
.build())
.build();
}
private static Map<String, Snapshot> enrichAllConfigs(
CodebaseContext context,
JdtIntelligenceProvider intelligence,
EnrichmentService enrichmentService,
List<String> configNames) {
Map<String, Snapshot> snapshots = new LinkedHashMap<>();
StateMachineAggregator aggregator = new StateMachineAggregator(context);
for (String configName : configNames) {
TypeDeclaration configType = context.getTypeDeclaration(configName);
if (configType == null) {
configType = context.getTypeDeclarations().stream()
.filter(td -> td.getName().getIdentifier().equals(configName))
.findFirst()
.orElse(null);
}
assertThat(configType).as("config type %s", configName).isNotNull();
java.util.List<Transition> transitions = aggregator.aggregateTransitions(configType);
AnalysisResult result = AnalysisResult.builder()
.name(configName)
.transitions(transitions)
.build();
enrichmentService.enrich(result, context, intelligence);
snapshots.put(context.getFqn(configType), Snapshot.from(result));
}
return snapshots;
}
private record Snapshot(int transitionCount, List<String> eventNames) {
static Snapshot from(AnalysisResult result) {
List<String> events = result.getTransitions().stream()
.map(t -> t.getEvent() != null ? t.getEvent().rawName() : null)
.filter(java.util.Objects::nonNull)
.map(Snapshot::stripLiteralQuotes)
.sorted()
.collect(Collectors.toList());
return new Snapshot(result.getTransitions().size(), events);
}
private static String stripLiteralQuotes(String name) {
if (name.length() >= 2 && name.startsWith("\"") && name.endsWith("\"")) {
return name.substring(1, name.length() - 1);
}
return name;
}
}
}

View File

@@ -0,0 +1,94 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Documents how traced call-path expressions are classified: map lookups vs bean getter chains.
*/
class CallGraphExpressionShapeTest {
@TempDir
Path tempDir;
private HeuristicCallGraphEngine engine;
private static final String SCOPE = "com.example.Service.handle";
@BeforeEach
void setUp() throws IOException {
String source = """
package com.example;
import java.util.Map;
import java.util.function.Supplier;
public class Service {
private static final Map<String, String> ROUTES = Map.of("k", "v");
private EventWrapper eventWrapper;
private Supplier<String> eventSupplier;
public void handle(String commandKey) {
ROUTES.get(commandKey);
Routes.ROUTES.get(commandKey);
eventWrapper.getEvent();
eventSupplier.get();
}
}
class Routes {
static final Map<String, String> ROUTES = Map.of("k", "v");
}
class EventWrapper {
String getEvent() { return null; }
}
""";
Files.writeString(tempDir.resolve("Service.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
engine = new HeuristicCallGraphEngine(context);
}
@Test
void extractMethodSuffixShouldKeepMapLookupIntact() {
String[] parts = engine.extractMethodSuffix("ROUTES.get(commandKey)", "", SCOPE);
assertThat(parts[0]).isEqualTo("ROUTES.get(commandKey)");
assertThat(parts[1]).isEmpty();
}
@Test
void extractMethodSuffixShouldKeepQualifiedMapLookupIntact() {
String[] parts = engine.extractMethodSuffix("Routes.ROUTES.get(commandKey)", "", SCOPE);
assertThat(parts[0]).isEqualTo("Routes.ROUTES.get(commandKey)");
assertThat(parts[1]).isEmpty();
}
@Test
void extractMethodSuffixShouldSplitBeanGetterChains() {
String[] parts = engine.extractMethodSuffix("eventWrapper.getEvent()", "", SCOPE);
assertThat(parts[0]).isEqualTo("eventWrapper");
assertThat(parts[1]).isEqualTo(".getEvent()");
}
@Test
void extractMethodSuffixShouldSplitNoArgGetCalls() {
String[] parts = engine.extractMethodSuffix("eventSupplier.get()", "", SCOPE);
assertThat(parts[0]).isEqualTo("eventSupplier");
assertThat(parts[1]).isEqualTo(".get()");
}
@Test
void extractMethodSuffixShouldSplitChainedBeanGetters() {
String[] parts = engine.extractMethodSuffix("this.eventWrapper.getEvent().name()", ".name()", SCOPE);
assertThat(parts[0]).isEqualTo("this.eventWrapper");
assertThat(parts[1]).startsWith(".getEvent()");
}
}

View File

@@ -0,0 +1,420 @@
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.MatchedTransition;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Path;
import java.util.List;
import static click.kamil.springstatemachineexporter.analysis.service.CentralDispatcherTestSupport.*;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Readable unit tests for central-dispatcher endpoint resolution.
*
* <p>Each test embeds a miniature codebase as a string so humans and agents can see the full
* REST → dispatcher → sendEvent pipeline without opening another module.
*
* <p>DTO setter/getter propagation is covered by {@code HeuristicCallGraphEngineTypeTest}.
* Cross-hop routing helpers that return computed strings are covered by literal-forwarding tests above.
*/
class CentralDispatcherResolutionTest {
private static final String CONTROLLER = "com.example.ApiController";
private static final String STATE_MACHINE = "com.example.StateMachine";
private static final String MACHINE_CONFIG = "com.example.OrderStateMachineConfig";
/**
* REST endpoint passes two string literals into a shared dispatcher.
* The dispatcher selects the machine branch, then resolves the enum constant.
*
* <pre>
* payOrder() → dispatch("ORDER", "PAY") → OrderEvent.PAY
* shipOrder() → dispatch("ORDER", "SHIP") → OrderEvent.SHIP
* </pre>
*/
@Test
void twoFieldCentralDispatcherShouldResolveDistinctEventsPerEndpoint(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class ApiController {
CentralDispatcher dispatcher;
public void payOrder() { dispatcher.dispatch("ORDER", "PAY"); }
public void shipOrder() { dispatcher.dispatch("ORDER", "SHIP"); }
}
class CentralDispatcher {
public void dispatch(String domain, String action) {
StateMachine sm = new StateMachine();
if ("ORDER".equals(domain)) {
OrderEvent ev = OrderEvent.valueOf(action);
sm.sendEvent(ev);
}
}
}
enum OrderEvent { PAY, SHIP, CANCEL }
class StateMachine { public void sendEvent(OrderEvent e) {} }
class OrderStateMachineConfig {}
""";
CodebaseContext context = scanSource(source, tempDir);
CallChain payChain = resolveChain(context, CONTROLLER, "payOrder", STATE_MACHINE);
CallChain shipChain = resolveChain(context, CONTROLLER, "shipOrder", STATE_MACHINE);
assertPolyEvents(payChain, "OrderEvent.PAY");
assertPolyEvents(shipChain, "OrderEvent.SHIP");
MatchedTransition pay = linkEndpointToTransition(
payChain,
MACHINE_CONFIG,
transition("NEW", "PAID", "OrderEvent.PAY"),
transition("PAID", "SHIPPED", "OrderEvent.SHIP"));
MatchedTransition ship = linkEndpointToTransition(
shipChain,
MACHINE_CONFIG,
transition("NEW", "PAID", "OrderEvent.PAY"),
transition("PAID", "SHIPPED", "OrderEvent.SHIP"));
assertLinkedEvent(pay, "OrderEvent.PAY");
assertLinkedEvent(ship, "OrderEvent.SHIP");
}
/**
* Three request dimensions (domain, action, version) are passed as literals and folded
* together by a routing helper before the central dispatcher fires the SM event.
*
* <pre>
* payV2() → dispatch("ORDER", "PAY", "v2") → OrderEvent.valueOf(action)
* shipV2() → dispatch("ORDER", "SHIP", "v2") → OrderEvent.valueOf(action)
* </pre>
*
* <p>The third field is forwarded for API shape parity; routing uses domain + action literals.
*/
@Test
void threeFieldCentralDispatcherShouldResolveFromCombinedRoutingKey(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class ApiController {
CentralDispatcher dispatcher;
public void payV2() { dispatcher.dispatch("ORDER", "PAY", "v2"); }
public void shipV2() { dispatcher.dispatch("ORDER", "SHIP", "v2"); }
}
class CentralDispatcher {
public void dispatch(String domain, String action, String version) {
StateMachine sm = new StateMachine();
if ("ORDER".equals(domain)) {
OrderEvent ev = OrderEvent.valueOf(action);
sm.sendEvent(ev);
}
}
}
enum OrderEvent { PAY, SHIP, CANCEL }
class StateMachine { public void sendEvent(OrderEvent e) {} }
class OrderStateMachineConfig {}
""";
CodebaseContext context = scanSource(source, tempDir);
CallChain payChain = resolveChain(context, CONTROLLER, "payV2", STATE_MACHINE);
CallChain shipChain = resolveChain(context, CONTROLLER, "shipV2", STATE_MACHINE);
assertPolyEvents(payChain, "OrderEvent.PAY");
assertPolyEvents(shipChain, "OrderEvent.SHIP");
MatchedTransition pay = linkEndpointToTransition(
payChain, MACHINE_CONFIG,
transition("NEW", "PAID", "OrderEvent.PAY"),
transition("PAID", "SHIPPED", "OrderEvent.SHIP"));
MatchedTransition ship = linkEndpointToTransition(
shipChain, MACHINE_CONFIG,
transition("NEW", "PAID", "OrderEvent.PAY"),
transition("PAID", "SHIPPED", "OrderEvent.SHIP"));
assertLinkedEvent(pay, "OrderEvent.PAY");
assertLinkedEvent(ship, "OrderEvent.SHIP");
}
/**
* Gateway receives three separate fields from the endpoint and forwards them to the
* central dispatcher (same routing core as the three-field test, without DTO indirection).
*/
@Test
void gatewayShouldForwardThreeRequestFieldsToCentralDispatcher(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class ApiController {
Gateway gateway;
public void submit() {
gateway.handle("ORDER", "SUBMIT", "v2");
}
public void approve() {
gateway.handle("ORDER", "APPROVE", "v2");
}
}
class Gateway {
CentralDispatcher dispatcher;
void handle(String domain, String action, String version) {
dispatcher.dispatch(domain, action, version);
}
}
class CentralDispatcher {
public void dispatch(String domain, String action, String version) {
StateMachine sm = new StateMachine();
if ("ORDER".equals(domain)) {
OrderEvent ev = OrderEvent.valueOf(action);
sm.sendEvent(ev);
}
}
}
enum OrderEvent { SUBMIT, APPROVE, REJECT }
class StateMachine { public void sendEvent(OrderEvent e) {} }
class OrderStateMachineConfig {}
""";
CodebaseContext context = scanSource(source, tempDir);
CallChain submitChain = resolveChain(context, CONTROLLER, "submit", STATE_MACHINE);
CallChain approveChain = resolveChain(context, CONTROLLER, "approve", STATE_MACHINE);
assertPolyEvents(submitChain, "OrderEvent.SUBMIT");
assertPolyEvents(approveChain, "OrderEvent.APPROVE");
assertLinkedEvent(
linkEndpointToTransition(
submitChain, MACHINE_CONFIG,
transition("DRAFT", "SUBMITTED", "OrderEvent.SUBMIT"),
transition("SUBMITTED", "APPROVED", "OrderEvent.APPROVE")),
"OrderEvent.SUBMIT");
assertLinkedEvent(
linkEndpointToTransition(
approveChain, MACHINE_CONFIG,
transition("DRAFT", "SUBMITTED", "OrderEvent.SUBMIT"),
transition("SUBMITTED", "APPROVED", "OrderEvent.APPROVE")),
"OrderEvent.APPROVE");
}
/**
* Two concrete state-machine configs share one central dispatcher class.
* Each endpoint must link only to transitions from its own config, not the sibling machine.
*/
@Test
void sharedCentralDispatcherShouldRouteEndpointsToCorrectMachineConfig(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class OrderApi {
SharedDispatcher dispatcher;
public void pay() { dispatcher.payOrder(); }
}
public class ShipmentApi {
SharedDispatcher dispatcher;
public void dispatchShipment() { dispatcher.dispatchShipment(); }
}
class SharedDispatcher {
public void payOrder() {
OrderMachine sm = new OrderMachine();
sm.sendEvent(OrderEvent.PAY);
}
public void dispatchShipment() {
ShipmentMachine sm = new ShipmentMachine();
sm.sendEvent(ShipmentEvent.DISPATCH);
}
}
enum OrderEvent { PAY, SHIP }
enum ShipmentEvent { DISPATCH, DELIVER }
class OrderMachine { public void sendEvent(OrderEvent e) {} }
class ShipmentMachine { public void sendEvent(ShipmentEvent e) {} }
class OrderStateMachineConfig {}
class ShipmentStateMachineConfig {}
""";
CodebaseContext context = scanSource(source, tempDir);
CallChain orderChain = resolveChain(context, "com.example.OrderApi", "pay", "com.example.OrderMachine");
CallChain shipmentChain = resolveChain(context, "com.example.ShipmentApi", "dispatchShipment", "com.example.ShipmentMachine");
assertPolyEvents(orderChain, "OrderEvent.PAY");
assertPolyEvents(shipmentChain, "ShipmentEvent.DISPATCH");
MatchedTransition orderLink = linkEndpointToTransition(
orderChain,
"com.example.OrderStateMachineConfig",
transition("NEW", "PAID", "OrderEvent.PAY"),
transition("PAID", "SHIPPED", "OrderEvent.SHIP"));
MatchedTransition shipmentLink = linkEndpointToTransition(
shipmentChain,
"com.example.ShipmentStateMachineConfig",
transition("READY", "IN_TRANSIT", "ShipmentEvent.DISPATCH"),
transition("IN_TRANSIT", "DELIVERED", "ShipmentEvent.DELIVER"));
assertLinkedEvent(orderLink, "OrderEvent.PAY");
assertLinkedEvent(shipmentLink, "ShipmentEvent.DISPATCH");
// Negative check: order endpoint must not link against shipment transitions.
AnalysisResult wrongMachine = AnalysisResult.builder()
.name("com.example.ShipmentStateMachineConfig")
.transitions(List.of(transition("READY", "IN_TRANSIT", "ShipmentEvent.DISPATCH")))
.metadata(click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata.builder()
.callChains(List.of(orderChain))
.build())
.build();
new TransitionLinkerEnricher().enrich(wrongMachine, null, null);
assertThat(wrongMachine.getMetadata().getCallChains().get(0).getMatchedTransitions()).isNull();
}
/**
* Intermediate enum hop: endpoint literal → {@code DomainCommand} → concrete SM event.
* Uses named dispatcher methods so each endpoint resolves to a single transition.
*/
@Test
void intermediateEnumCentralDispatcherShouldResolveSingleEventPerEndpoint(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class ApiController {
CommandGateway gateway;
public void pay() { gateway.payOrder(); }
public void ship() { gateway.shipOrder(); }
}
enum DomainCommand { ORDER_PAY, ORDER_SHIP }
enum OrderEvent { PAY, SHIP }
class CommandGateway {
CentralDispatcher central;
void payOrder() { central.orderPay(); }
void shipOrder() { central.orderShip(); }
}
class CentralDispatcher {
public void orderPay() {
StateMachine sm = new StateMachine();
sm.sendEvent(OrderEvent.PAY);
}
public void orderShip() {
StateMachine sm = new StateMachine();
sm.sendEvent(OrderEvent.SHIP);
}
}
class StateMachine { public void sendEvent(OrderEvent e) {} }
class OrderStateMachineConfig {}
""";
CodebaseContext context = scanSource(source, tempDir);
CallChain payChain = resolveChain(context, CONTROLLER, "pay", STATE_MACHINE);
CallChain shipChain = resolveChain(context, CONTROLLER, "ship", STATE_MACHINE);
assertPolyEvents(payChain, "OrderEvent.PAY");
assertPolyEvents(shipChain, "OrderEvent.SHIP");
assertLinkedEvent(
linkEndpointToTransition(payChain, MACHINE_CONFIG,
transition("NEW", "PAID", "OrderEvent.PAY"),
transition("PAID", "SHIPPED", "OrderEvent.SHIP")),
"OrderEvent.PAY");
assertLinkedEvent(
linkEndpointToTransition(shipChain, MACHINE_CONFIG,
transition("NEW", "PAID", "OrderEvent.PAY"),
transition("PAID", "SHIPPED", "OrderEvent.SHIP")),
"OrderEvent.SHIP");
}
/**
* Tier 2 — REST → gateway → central dispatcher → {@code richEvent.getType()} → {@code sendEvent}.
*
* <pre>
* pay() → gateway.payWithRichEvent(new PayRichEvent()) → central.orderPay(e) → e.getType() → OrderEvent.PAY
* ship() → gateway.shipWithRichEvent(new ShipRichEvent()) → central.orderShip(e) → e.getType() → OrderEvent.SHIP
* </pre>
*/
@Test
void richEventCentralDispatcherShouldResolveDistinctEventsPerEndpoint(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class ApiController {
CommandGateway gateway;
public void pay() { gateway.payWithRichEvent(new PayRichEvent()); }
public void ship() { gateway.shipWithRichEvent(new ShipRichEvent()); }
}
interface RichOrderEvent { OrderEvent getType(); }
class PayRichEvent implements RichOrderEvent {
public OrderEvent getType() { return OrderEvent.PAY; }
}
class ShipRichEvent implements RichOrderEvent {
public OrderEvent getType() { return OrderEvent.SHIP; }
}
class CommandGateway {
CentralDispatcher central;
void payWithRichEvent(RichOrderEvent event) { central.orderPay(event); }
void shipWithRichEvent(RichOrderEvent event) { central.orderShip(event); }
}
class CentralDispatcher {
public void orderPay(RichOrderEvent event) {
StateMachine sm = new StateMachine();
sm.sendEvent(event.getType());
}
public void orderShip(RichOrderEvent event) {
StateMachine sm = new StateMachine();
sm.sendEvent(event.getType());
}
}
enum OrderEvent { PAY, SHIP, CANCEL }
class StateMachine { public void sendEvent(OrderEvent e) {} }
class OrderStateMachineConfig {}
""";
CodebaseContext context = scanSource(source, tempDir);
CallChain payChain = resolveChain(context, CONTROLLER, "pay", STATE_MACHINE);
CallChain shipChain = resolveChain(context, CONTROLLER, "ship", STATE_MACHINE);
assertPolyEvents(payChain, "OrderEvent.PAY");
assertPolyEvents(shipChain, "OrderEvent.SHIP");
assertLinkedEvent(
linkEndpointToTransition(payChain, MACHINE_CONFIG,
transition("NEW", "PAID", "OrderEvent.PAY"),
transition("PAID", "SHIPPED", "OrderEvent.SHIP")),
"OrderEvent.PAY");
assertLinkedEvent(
linkEndpointToTransition(shipChain, MACHINE_CONFIG,
transition("NEW", "PAID", "OrderEvent.PAY"),
transition("PAID", "SHIPPED", "OrderEvent.SHIP")),
"OrderEvent.SHIP");
}
/**
* Tier 2 — chained accessor {@code wrapper.getEvent().getType()} through a central dispatcher.
*/
@Test
void chainedRichEventAccessorShouldResolveThroughCentralDispatcher(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class ApiController {
CentralDispatcher dispatcher;
public void pay(EventWrapper wrapper) {
dispatcher.fireOrderEvent(wrapper);
}
}
class CentralDispatcher {
public void fireOrderEvent(EventWrapper wrapper) {
StateMachine sm = new StateMachine();
sm.sendEvent(wrapper.getEvent().getType());
}
}
class EventWrapper {
public RichEvent getEvent() { return new RichEvent(); }
}
class RichEvent {
public OrderEvent getType() { return OrderEvent.PAY; }
}
enum OrderEvent { PAY, SHIP }
class StateMachine { public void sendEvent(OrderEvent e) {} }
class OrderStateMachineConfig {}
""";
CodebaseContext context = scanSource(source, tempDir);
CallChain chain = resolveChain(context, CONTROLLER, "pay", STATE_MACHINE);
assertPolyEvents(chain, "OrderEvent.PAY");
assertLinkedEvent(
linkEndpointToTransition(chain, MACHINE_CONFIG,
transition("NEW", "PAID", "OrderEvent.PAY"),
transition("PAID", "SHIPPED", "OrderEvent.SHIP")),
"OrderEvent.PAY");
}
}

View File

@@ -0,0 +1,91 @@
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.MatchedTransition;
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 java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Small helpers so dispatcher tests read as documentation of expected endpoint → transition behaviour.
*/
final class CentralDispatcherTestSupport {
private CentralDispatcherTestSupport() {
}
static CodebaseContext scanSource(String source, Path tempDir) throws Exception {
Files.writeString(tempDir.resolve("App.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
return context;
}
static CallChain resolveChain(
CodebaseContext context,
String controllerClass,
String controllerMethod,
String stateMachineClass) {
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
EntryPoint entryPoint = EntryPoint.builder()
.className(controllerClass)
.methodName(controllerMethod)
.build();
TriggerPoint trigger = TriggerPoint.builder()
.className(stateMachineClass)
.methodName("sendEvent")
.event("e")
.build();
List<CallChain> chains = engine.findChains(List.of(entryPoint), List.of(trigger));
assertThat(chains)
.as("expected one call chain from %s.%s to %s.sendEvent", controllerClass, controllerMethod, stateMachineClass)
.hasSize(1);
return chains.get(0);
}
static void assertPolyEvents(CallChain chain, String... expectedEvents) {
assertThat(chain.getTriggerPoint().getPolymorphicEvents())
.containsExactlyInAnyOrder(expectedEvents);
}
static Transition transition(String source, String target, String eventFqn) {
Transition transition = new Transition();
transition.setSourceStates(List.of(State.of(source, source)));
transition.setTargetStates(List.of(State.of(target, target)));
transition.setEvent(Event.of(eventFqn, eventFqn));
return transition;
}
static MatchedTransition linkEndpointToTransition(
CallChain chain,
String machineConfigName,
Transition... machineTransitions) {
AnalysisResult result = AnalysisResult.builder()
.name(machineConfigName)
.transitions(List.of(machineTransitions))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
.build();
new TransitionLinkerEnricher().enrich(result, null, null);
List<MatchedTransition> matched = result.getMetadata().getCallChains().get(0).getMatchedTransitions();
assertThat(matched)
.as("endpoint should resolve to exactly one SM transition")
.hasSize(1);
return matched.get(0);
}
static void assertLinkedEvent(MatchedTransition matched, String expectedEvent) {
assertThat(matched.getEvent()).isEqualTo(expectedEvent);
}
}

View File

@@ -0,0 +1,136 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.enricher.TransitionLinkerEnricher;
import click.kamil.springstatemachineexporter.analysis.model.*;
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 static org.assertj.core.api.Assertions.assertThat;
class DispatcherEndpointTest {
@Test
void shouldLinkOnlySpecificEventWhenEndpointPassesLiteralsThroughDispatcher(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class OrderController {
private Dispatcher dispatcher;
public void payOrder() { dispatcher.dispatch("ORDER", "PAY"); }
public void shipOrder() { dispatcher.dispatch("ORDER", "SHIP"); }
}
class Dispatcher {
public void dispatch(String type, String eventStr) {
StateMachine sm = new StateMachine();
if ("ORDER".equals(type)) {
OrderEvent ev = OrderEvent.valueOf(eventStr);
sm.sendEvent(ev);
}
}
}
enum OrderEvent { PAY, SHIP, CANCEL }
class StateMachine { public void sendEvent(OrderEvent e) {} }
""";
Files.writeString(tempDir.resolve("App.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
EntryPoint payEp = EntryPoint.builder().className("com.example.OrderController").methodName("payOrder").build();
TriggerPoint trigger = TriggerPoint.builder().className("com.example.StateMachine").methodName("sendEvent").event("e").build();
CallChain payChain = engine.findChains(List.of(payEp), List.of(trigger)).get(0);
TriggerPoint resolved = payChain.getTriggerPoint();
assertThat(resolved.getPolymorphicEvents())
.as("resolved trigger=%s eventTypeFqn=%s external=%s", resolved.getEvent(), resolved.getEventTypeFqn(), resolved.isExternal())
.contains("OrderEvent.PAY")
.doesNotContain("OrderEvent.SHIP", "OrderEvent.CANCEL");
Transition payT = new Transition();
payT.setSourceStates(List.of(State.of("NEW", "OrderState.NEW")));
payT.setTargetStates(List.of(State.of("PAID", "OrderState.PAID")));
payT.setEvent(Event.of("OrderEvent.PAY", "com.example.OrderEvent.PAY"));
Transition shipT = new Transition();
shipT.setSourceStates(List.of(State.of("PAID", "OrderState.PAID")));
shipT.setTargetStates(List.of(State.of("SHIPPED", "OrderState.SHIPPED")));
shipT.setEvent(Event.of("OrderEvent.SHIP", "com.example.OrderEvent.SHIP"));
assertThat(new click.kamil.springstatemachineexporter.analysis.enricher.matching.StrictFqnMatchingEngine()
.matches(payT.getEvent(), resolved)).isTrue();
AnalysisResult result = AnalysisResult.builder()
.name("OrderStateMachineConfig")
.transitions(List.of(payT, shipT))
.metadata(CodebaseMetadata.builder().callChains(List.of(payChain)).build())
.build();
new TransitionLinkerEnricher().enrich(result, null, null);
assertThat(result.getMetadata().getCallChains().get(0).getMatchedTransitions())
.hasSize(1)
.first()
.extracting("event")
.isEqualTo("OrderEvent.PAY");
}
@Test
void shouldNotLinkTransitionsForExternalGenericDispatchEndpoint(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class OrderController {
private Dispatcher dispatcher;
public void transition(String machineType, String event) {
dispatcher.dispatch(machineType, event);
}
}
class Dispatcher {
public void dispatch(String type, String eventStr) {
StateMachine sm = new StateMachine();
if ("ORDER".equals(type)) {
OrderEvent ev = OrderEvent.valueOf(eventStr);
sm.sendEvent(ev);
}
}
}
enum OrderEvent { PAY, SHIP }
class StateMachine { public void sendEvent(OrderEvent e) {} }
""";
Files.writeString(tempDir.resolve("App.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
EntryPoint entry = EntryPoint.builder()
.className("com.example.OrderController")
.methodName("transition")
.build();
TriggerPoint trigger = TriggerPoint.builder()
.className("com.example.StateMachine")
.methodName("sendEvent")
.event("e")
.build();
CallChain chain = engine.findChains(List.of(entry), List.of(trigger)).get(0);
assertThat(chain.getTriggerPoint().getPolymorphicEvents()).isEmpty();
Transition payT = new Transition();
payT.setSourceStates(List.of(State.of("NEW", "OrderState.NEW")));
payT.setTargetStates(List.of(State.of("PAID", "OrderState.PAID")));
payT.setEvent(Event.of("OrderEvent.PAY", "com.example.OrderEvent.PAY"));
AnalysisResult result = AnalysisResult.builder()
.name("OrderStateMachineConfig")
.transitions(List.of(payT))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
.build();
new TransitionLinkerEnricher().enrich(result, null, null);
assertThat(result.getMetadata().getCallChains().get(0).getMatchedTransitions()).isNull();
}
}

View File

@@ -269,6 +269,7 @@ class EnterpriseBugsTest {
TriggerPoint triggerPoint = TriggerPoint.builder()
.event("OrderEvents.valueOf(someVar)")
.polymorphicEvents(List.of("OrderEvents.PAY"))
.build();
assertThat(engine.matches(smEvent, triggerPoint)).isTrue();

View File

@@ -0,0 +1,119 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.AST;
import org.eclipse.jdt.core.dom.ASTNode;
import org.eclipse.jdt.core.dom.ASTParser;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import static org.assertj.core.api.Assertions.assertThat;
class ExpressionAccessClassifierTest {
@TempDir
Path tempDir;
private CodebaseContext context;
private ExpressionAccessClassifier classifier;
private static final String SCOPE = "com.example.Service.handle";
@BeforeEach
void setUp() throws IOException {
String source = """
package com.example;
import java.util.Map;
import java.util.function.Supplier;
public class Service {
private static final Map<String, String> ROUTES = Map.of("k", "v");
private EventWrapper wrapper;
private Supplier<String> eventSupplier;
public void handle(String commandKey) {
ROUTES.get(commandKey);
Routes.ROUTES.get(commandKey);
wrapper.getEvent();
eventSupplier.get();
}
}
class Routes {
static final Map<String, String> ROUTES = Map.of("k", "v");
}
class EventWrapper {
String getEvent() { return null; }
}
""";
Files.writeString(tempDir.resolve("Service.java"), source);
context = new CodebaseContext();
context.scan(tempDir);
Map<String, ASTNode> cache = new ConcurrentHashMap<>();
ConstantResolver constantResolver = new ConstantResolver();
VariableTracer variableTracer = new VariableTracer(context, constantResolver);
classifier = new ExpressionAccessClassifier(
context,
variableTracer,
constantResolver,
expr -> cache.computeIfAbsent(expr, ExpressionAccessClassifierTest::parseExpression));
}
@Test
void shouldClassifyMapLookupByDeclaredFieldType() {
assertThat(classifier.classifyTrailingAccess("ROUTES.get(commandKey)", SCOPE))
.isEqualTo(ExpressionAccessClassifier.AccessKind.KEYED_LOOKUP);
}
@Test
void shouldClassifyQualifiedStaticMapLookup() {
assertThat(classifier.classifyTrailingAccess("Routes.ROUTES.get(commandKey)", SCOPE))
.isEqualTo(ExpressionAccessClassifier.AccessKind.KEYED_LOOKUP);
}
@Test
void shouldClassifyBeanGetterAsTraceable() {
assertThat(classifier.classifyTrailingAccess("wrapper.getEvent()", SCOPE))
.isEqualTo(ExpressionAccessClassifier.AccessKind.TRACEABLE_ACCESSOR);
}
@Test
void shouldClassifySupplierGetAsTraceable() {
assertThat(classifier.classifyTrailingAccess("eventSupplier.get()", SCOPE))
.isEqualTo(ExpressionAccessClassifier.AccessKind.TRACEABLE_ACCESSOR);
}
@Test
void shouldClassifyGetOrDefaultAsKeyedLookup() {
assertThat(classifier.classifyTrailingAccess("ROUTES.getOrDefault(commandKey, \"fallback\")", SCOPE))
.isEqualTo(ExpressionAccessClassifier.AccessKind.KEYED_LOOKUP);
}
@Test
void shouldDetectMapLikeAndListLikeTypes() {
assertThat(ExpressionAccessClassifier.isMapLikeType("Map<String, String>")).isTrue();
assertThat(ExpressionAccessClassifier.isMapLikeType("java.util.HashMap")).isTrue();
assertThat(ExpressionAccessClassifier.isIndexedCollectionType("List<OrderEvent>")).isTrue();
assertThat(ExpressionAccessClassifier.isMapLikeType("Supplier<String>")).isFalse();
}
private static ASTNode parseExpression(String expr) {
ASTParser parser = ASTParser.newParser(AST.JLS17);
Map<String, String> options = org.eclipse.jdt.core.JavaCore.getOptions();
org.eclipse.jdt.core.JavaCore.setComplianceOptions(org.eclipse.jdt.core.JavaCore.VERSION_17, options);
parser.setCompilerOptions(options);
parser.setKind(ASTParser.K_EXPRESSION);
parser.setSource(expr.toCharArray());
return parser.createAST(null);
}
}

View File

@@ -420,8 +420,8 @@ class HeuristicCallGraphEngineCoreTest {
assertThat(chains).hasSize(1);
CallChain chain = chains.get(0);
// It might not fully resolve chained method calls, but it must not crash!
assertThat(chain.getTriggerPoint().getEvent()).isEqualTo("wrapper.getEvent().getType()");
assertThat(chain.getTriggerPoint().getPolymorphicEvents())
.containsExactlyInAnyOrder("OrderEvents.PAY");
}
@Test

View File

@@ -0,0 +1,103 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.exporter.JsonExporter;
import click.kamil.springstatemachineexporter.service.ExportService;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
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.stream.StreamSupport;
import static org.assertj.core.api.Assertions.assertThat;
/**
* End-to-end verification for {@code state_machines/layered_dispatcher_sample}:
* REST endpoint → string key → {@code DomainCommand} → transition event → single matched transition.
*/
class LayeredDispatcherSampleTest {
private static Path findProjectRoot() {
Path current = Path.of(".").toAbsolutePath();
while (current != null && !Files.exists(current.resolve("settings.gradle"))) {
current = current.getParent();
}
return current;
}
@Test
void shouldLinkExactlyOneTransitionPerDedicatedEndpoint(@TempDir Path tempDir) throws Exception {
Path sampleRoot = findProjectRoot().resolve("state_machines/layered_dispatcher_sample");
ExportService exportService = new ExportService(List.of(new JsonExporter()));
exportService.runExporter(sampleRoot, tempDir, List.of("json"), true, List.of(), null, null,
click.kamil.springstatemachineexporter.exporter.EnumFormat.fn,
click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, true);
ObjectMapper mapper = new ObjectMapper();
JsonNode orderMachine = readMachineJson(tempDir, "StandardOrderStateMachineConfiguration", mapper);
JsonNode documentMachine = readMachineJson(tempDir, "StandardDocumentStateMachineConfiguration", mapper);
assertEndpointLinksSingleTransition(orderMachine, "POST /api/orders/pay",
"OrderTransitionEvent.PAY", "OrderState.NEW", "OrderState.PAID");
assertEndpointLinksSingleTransition(orderMachine, "POST /api/orders/ship",
"OrderTransitionEvent.SHIP", "OrderState.PAID", "OrderState.SHIPPED");
assertEndpointLinksSingleTransition(orderMachine, "POST /api/orders/cancel",
"OrderTransitionEvent.CANCEL", "OrderState.PAID", "OrderState.CANCELLED");
assertEndpointLinksSingleTransition(documentMachine, "POST /api/documents/submit",
"DocumentTransitionEvent.SUBMIT", "DocumentState.DRAFT", "DocumentState.SUBMITTED");
assertEndpointLinksSingleTransition(documentMachine, "POST /api/documents/approve",
"DocumentTransitionEvent.APPROVE", "DocumentState.SUBMITTED", "DocumentState.APPROVED");
assertEndpointLinksSingleTransition(documentMachine, "POST /api/documents/reject",
"DocumentTransitionEvent.REJECT", "DocumentState.SUBMITTED", "DocumentState.REJECTED");
assertEndpointLinksSingleTransition(orderMachine, "POST /api/orders/rich/pay",
"OrderTransitionEvent.PAY", "OrderState.NEW", "OrderState.PAID");
assertEndpointLinksSingleTransition(orderMachine, "POST /api/orders/rich/ship",
"OrderTransitionEvent.SHIP", "OrderState.PAID", "OrderState.SHIPPED");
}
private static JsonNode readMachineJson(Path outputDir, String configBaseName, ObjectMapper mapper)
throws Exception {
Path machineDir;
try (var stream = Files.list(outputDir)) {
machineDir = stream
.filter(Files::isDirectory)
.filter(path -> path.getFileName().toString().endsWith(configBaseName))
.findFirst()
.orElseThrow(() -> new IllegalStateException("No output for " + configBaseName));
}
Path jsonFile = machineDir.resolve(machineDir.getFileName() + ".json");
return mapper.readTree(jsonFile.toFile());
}
private static void assertEndpointLinksSingleTransition(
JsonNode machineJson,
String endpointName,
String expectedEvent,
String expectedSource,
String expectedTarget) {
JsonNode chain = findCallChain(machineJson, endpointName);
assertThat(chain).as("call chain for %s", endpointName).isNotNull();
JsonNode matched = chain.get("matchedTransitions");
assertThat(matched).as("matchedTransitions for %s", endpointName).isNotNull().hasSize(1);
assertThat(matched.get(0).get("event").asText()).isEqualTo(expectedEvent);
assertThat(matched.get(0).get("sourceState").asText()).isEqualTo(expectedSource);
assertThat(matched.get(0).get("targetState").asText()).isEqualTo(expectedTarget);
}
private static JsonNode findCallChain(JsonNode machineJson, String endpointName) {
JsonNode callChains = machineJson.path("metadata").path("callChains");
if (!callChains.isArray()) {
return null;
}
return StreamSupport.stream(callChains.spliterator(), false)
.filter(chain -> endpointName.equals(chain.path("entryPoint").path("name").asText()))
.findFirst()
.orElse(null);
}
}

View File

@@ -0,0 +1,470 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.Expression;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.ReturnStatement;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import static click.kamil.springstatemachineexporter.analysis.service.CentralDispatcherTestSupport.*;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Robust coverage for compile-time literal dataflow: map lookup, string transforms, array indexing,
* extractor expansion, and call-graph end-to-end resolution.
*/
class LiteralDataFlowResolutionTest {
private static CodebaseContext scan(Path tempDir, String fileName, String source) throws IOException {
Files.writeString(tempDir.resolve(fileName), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
return context;
}
private static Expression returnExpression(Path tempDir, String fileName, String source, String typeName, String methodName)
throws IOException {
CodebaseContext context = scan(tempDir, fileName, source);
TypeDeclaration td = context.getTypeDeclaration(typeName);
MethodDeclaration method = findMethod(td, methodName);
ReturnStatement rs = (ReturnStatement) method.getBody().statements().get(0);
return rs.getExpression();
}
private static MethodDeclaration findMethod(TypeDeclaration td, String methodName) {
for (MethodDeclaration md : td.getMethods()) {
if (methodName.equals(md.getName().getIdentifier())) {
return md;
}
}
throw new IllegalArgumentException("Method not found: " + methodName);
}
@Nested
class ConstantResolverCases {
@Test
void shouldReturnNullForMapGetWithUnknownLiteralKey(@TempDir Path tempDir) throws IOException {
Expression expr = returnExpression(tempDir, "Routes.java", """
package com.example;
import java.util.Map;
public class Routes {
private static final Map<String, String> ROUTES = Map.of(
"order.pay", "OrderEvent.PAY",
"order.ship", "OrderEvent.SHIP"
);
public String miss() {
return ROUTES.get("order.cancel");
}
}
""", "com.example.Routes", "miss");
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
assertThat(new ConstantResolver().resolve(expr, context)).isNull();
}
@Test
void shouldReturnNullForMapGetWithDynamicKey(@TempDir Path tempDir) throws IOException {
Expression expr = returnExpression(tempDir, "Routes.java", """
package com.example;
import java.util.Map;
public class Routes {
private static final Map<String, String> ROUTES = Map.of(
"order.pay", "OrderEvent.PAY",
"order.ship", "OrderEvent.SHIP"
);
public String dynamic(String commandKey) {
return ROUTES.get(commandKey);
}
}
""", "com.example.Routes", "dynamic");
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
assertThat(new ConstantResolver().resolve(expr, context)).isNull();
}
@Test
void shouldResolveMapOfEntriesWithLiteralKey(@TempDir Path tempDir) throws IOException {
Expression expr = returnExpression(tempDir, "Routes.java", """
package com.example;
import java.util.Map;
public class Routes {
public String lookup() {
return Map.ofEntries(
Map.entry("order.pay", "OrderEvent.PAY"),
Map.entry("order.ship", "OrderEvent.SHIP")
).get("order.pay");
}
}
""", "com.example.Routes", "lookup");
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
assertThat(new ConstantResolver().resolve(expr, context)).isEqualTo("OrderEvent.PAY");
}
@Test
void shouldResolveMapGetOrDefaultWithLiteralKey(@TempDir Path tempDir) throws IOException {
Expression expr = returnExpression(tempDir, "Routes.java", """
package com.example;
import java.util.Map;
public class Routes {
private static final Map<String, String> ROUTES = Map.of("order.pay", "OrderEvent.PAY");
public String lookup() {
return ROUTES.getOrDefault("order.pay", "OrderEvent.FALLBACK");
}
}
""", "com.example.Routes", "lookup");
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
assertThat(new ConstantResolver().resolve(expr, context)).isEqualTo("OrderEvent.PAY");
}
@Test
void shouldNotFoldToUpperCaseOnVariableReceiver(@TempDir Path tempDir) throws IOException {
Expression expr = returnExpression(tempDir, "Caller.java", """
package com.example;
public class Caller {
public String dynamic(String action) {
return action.toUpperCase();
}
}
""", "com.example.Caller", "dynamic");
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
assertThat(new ConstantResolver().resolve(expr, context)).isNull();
}
@Test
void shouldFoldToLowerCaseOnLiteralReceiver(@TempDir Path tempDir) throws IOException {
Expression expr = returnExpression(tempDir, "Caller.java", """
package com.example;
public class Caller {
public String lower() {
return "PAY".toLowerCase();
}
}
""", "com.example.Caller", "lower");
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
assertThat(new ConstantResolver().resolve(expr, context)).isEqualTo("pay");
}
@Test
void shouldResolveInlineArrayLiteralWithIndexMath(@TempDir Path tempDir) throws IOException {
Expression expr = returnExpression(tempDir, "Table.java", """
package com.example;
public class Table {
public String pick() {
return new String[] {"OrderEvent.PAY", "OrderEvent.SHIP", "OrderEvent.CANCEL"}[1 + 1];
}
}
""", "com.example.Table", "pick");
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
assertThat(new ConstantResolver().resolve(expr, context)).isEqualTo("OrderEvent.CANCEL");
}
@Test
void shouldResolveArrayIndexWithSubtraction(@TempDir Path tempDir) throws IOException {
Expression expr = returnExpression(tempDir, "Table.java", """
package com.example;
public class Table {
private static final String[] EVENTS = {"OrderEvent.PAY", "OrderEvent.SHIP", "OrderEvent.CANCEL"};
public String pick() {
return EVENTS[2 - 1];
}
}
""", "com.example.Table", "pick");
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
assertThat(new ConstantResolver().resolve(expr, context)).isEqualTo("OrderEvent.SHIP");
}
@Test
void shouldReturnNullForDynamicArrayIndex(@TempDir Path tempDir) throws IOException {
Expression expr = returnExpression(tempDir, "Table.java", """
package com.example;
public class Table {
private static final String[] EVENTS = {"OrderEvent.PAY", "OrderEvent.SHIP"};
public String pick(int idx) {
return EVENTS[idx];
}
}
""", "com.example.Table", "pick");
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
assertThat(new ConstantResolver().resolve(expr, context)).isNull();
}
}
@Nested
class ConstantExtractorCases {
private List<String> extract(String source, String typeName, String methodName, Path tempDir) throws IOException {
Expression expr = returnExpression(tempDir, "App.java", source, typeName, methodName);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
ConstantExtractor extractor = new ConstantExtractor(context, new ConstantResolver(), mi -> null);
List<String> constants = new ArrayList<>();
extractor.extractConstantsFromExpression(expr, constants);
return constants;
}
@Test
void bareMapOfShouldExpandAllValuesNotRawMapEncoding(@TempDir Path tempDir) throws IOException {
List<String> constants = extract("""
package com.example;
import java.util.Map;
public class Routes {
public Map<String, String> all() {
return Map.of("order.pay", "OrderEvent.PAY", "order.ship", "OrderEvent.SHIP");
}
}
""", "com.example.Routes", "all", tempDir);
assertThat(constants)
.containsExactlyInAnyOrder("OrderEvent.PAY", "OrderEvent.SHIP")
.noneMatch(value -> value.startsWith("MAP:"));
}
@Test
void mapGetWithLiteralKeyShouldExtractSingleValue(@TempDir Path tempDir) throws IOException {
List<String> constants = extract("""
package com.example;
import java.util.Map;
public class Routes {
public String one() {
return Map.of("order.pay", "OrderEvent.PAY", "order.ship", "OrderEvent.SHIP")
.get("order.pay");
}
}
""", "com.example.Routes", "one", tempDir);
assertThat(constants).containsExactly("OrderEvent.PAY");
}
@Test
void staticMapGetWithDynamicKeyShouldExpandAllRouteValues(@TempDir Path tempDir) throws IOException {
List<String> constants = extract("""
package com.example;
import java.util.Map;
public class Routes {
private static final Map<String, String> ROUTES = Map.of(
"order.pay", "OrderEvent.PAY",
"order.ship", "OrderEvent.SHIP"
);
public String dynamic(String commandKey) {
return ROUTES.get(commandKey);
}
}
""", "com.example.Routes", "dynamic", tempDir);
assertThat(constants).contains("OrderEvent.PAY", "OrderEvent.SHIP");
}
@Test
void bareArrayLiteralShouldExpandAllElements(@TempDir Path tempDir) throws IOException {
List<String> constants = extract("""
package com.example;
public class Table {
public String[] all() {
return new String[] {"OrderEvent.PAY", "OrderEvent.SHIP", "OrderEvent.CANCEL"};
}
}
""", "com.example.Table", "all", tempDir);
assertThat(constants)
.containsExactlyInAnyOrder("OrderEvent.PAY", "OrderEvent.SHIP", "OrderEvent.CANCEL")
.noneMatch(value -> value.startsWith("ARRAY:"));
}
}
@Nested
class CallGraphIntegrationCases {
private static final String CONTROLLER = "com.example.ApiController";
private static final String STATE_MACHINE = "com.example.StateMachine";
@Test
void staticRoutesLiteralKeyShouldResolveSinglePolymorphicEvent(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
import java.util.Map;
public class ApiController {
StateMachine sm;
public void payOrder() {
sm.sendEvent(Routes.ROUTES.get("order.pay"));
}
}
class Routes {
static final Map<String, OrderEvent> ROUTES = Map.of(
"order.pay", OrderEvent.PAY,
"order.ship", OrderEvent.SHIP,
"order.cancel", OrderEvent.CANCEL
);
}
enum OrderEvent { PAY, SHIP, CANCEL }
class StateMachine { public void sendEvent(OrderEvent e) {} }
""";
CodebaseContext context = scanSource(source, tempDir);
CallChain chain = resolveChain(context, CONTROLLER, "payOrder", STATE_MACHINE);
assertPolyEvents(chain, "OrderEvent.PAY");
}
@Test
void literalToUpperCaseBeforeValueOfShouldResolveSingleEvent(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class ApiController {
StateMachine sm;
public void payOrder() {
sm.sendEvent(OrderEvent.valueOf("pay".toUpperCase()));
}
}
enum OrderEvent { PAY, SHIP, CANCEL }
class StateMachine { public void sendEvent(OrderEvent e) {} }
""";
CodebaseContext context = scanSource(source, tempDir);
CallChain chain = resolveChain(context, CONTROLLER, "payOrder", STATE_MACHINE);
assertPolyEvents(chain, "OrderEvent.PAY");
}
@Test
void variableToUpperCaseBeforeValueOfShouldNotNarrowToSingleEvent(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class ApiController {
StateMachine sm;
public void processAction(String action) {
sm.sendEvent(OrderEvent.valueOf(action.toUpperCase()));
}
}
enum OrderEvent { PAY, SHIP, CANCEL }
class StateMachine { public void sendEvent(OrderEvent e) {} }
""";
CodebaseContext context = scanSource(source, tempDir);
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
EntryPoint entryPoint = EntryPoint.builder()
.className(CONTROLLER)
.methodName("processAction")
.build();
TriggerPoint trigger = TriggerPoint.builder()
.className(STATE_MACHINE)
.methodName("sendEvent")
.event("e")
.build();
CallChain chain = engine.findChains(List.of(entryPoint), List.of(trigger)).get(0);
assertThat(chain.getTriggerPoint().getPolymorphicEvents())
.contains("OrderEvent.PAY", "OrderEvent.SHIP", "OrderEvent.CANCEL");
}
@Test
void arrayIndexMathShouldResolveSinglePolymorphicEvent(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class ApiController {
StateMachine sm;
public void cancelOrder() {
sm.sendEvent(RoutingTable.EVENTS[1 + 1]);
}
}
class RoutingTable {
static final OrderEvent[] EVENTS = {OrderEvent.PAY, OrderEvent.SHIP, OrderEvent.CANCEL};
}
enum OrderEvent { PAY, SHIP, CANCEL }
class StateMachine { public void sendEvent(OrderEvent e) {} }
""";
CodebaseContext context = scanSource(source, tempDir);
CallChain chain = resolveChain(context, CONTROLLER, "cancelOrder", STATE_MACHINE);
assertPolyEvents(chain, "OrderEvent.CANCEL");
}
@Test
void mapGetWithDynamicKeyShouldUnionAllRouteEvents(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
import java.util.Map;
public class ApiController {
StateMachine sm;
private static final Map<String, OrderEvent> ROUTES = Map.of(
"order.pay", OrderEvent.PAY,
"order.ship", OrderEvent.SHIP
);
public void dispatch(String commandKey) {
sm.sendEvent(ROUTES.get(commandKey));
}
}
enum OrderEvent { PAY, SHIP }
class StateMachine { public void sendEvent(OrderEvent e) {} }
""";
CodebaseContext context = scanSource(source, tempDir);
CallChain chain = resolveChain(context, CONTROLLER, "dispatch", STATE_MACHINE);
assertThat(chain.getTriggerPoint().getPolymorphicEvents())
.contains("OrderEvent.PAY", "OrderEvent.SHIP");
}
@Test
void crossClassStaticMapGetWithDynamicKeyShouldUnionAllRouteEvents(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
import java.util.Map;
public class ApiController {
StateMachine sm;
public void dispatch(String commandKey) {
sm.sendEvent(Routes.ROUTES.get(commandKey));
}
}
class Routes {
static final Map<String, OrderEvent> ROUTES = Map.of(
"order.pay", OrderEvent.PAY,
"order.ship", OrderEvent.SHIP
);
}
enum OrderEvent { PAY, SHIP }
class StateMachine { public void sendEvent(OrderEvent e) {} }
""";
CodebaseContext context = scanSource(source, tempDir);
CallChain chain = resolveChain(context, CONTROLLER, "dispatch", STATE_MACHINE);
assertThat(chain.getTriggerPoint().getPolymorphicEvents())
.contains("OrderEvent.PAY", "OrderEvent.SHIP");
}
}
}

View File

@@ -0,0 +1,101 @@
package click.kamil.springstatemachineexporter.analysis.service;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
/**
* Synthetic 1 → 3 → 12 state-machine config hierarchy for cache-correctness tests.
*
* <pre>
* RootAbstractSmConfig
* ├── BranchAAbstractSmConfig ── BranchA1..4SmConfig
* ├── BranchBAbstractSmConfig ── BranchB1..4SmConfig
* └── BranchCAbstractSmConfig ── BranchC1..4SmConfig
* </pre>
*
* <p>Each layer adds one external transition with a unique event name so every concrete
* config produces a distinct snapshot while sharing the same scanned {@code CodebaseContext}.
*/
final class TwelveConfigHierarchyTestSupport {
private static final String PKG = "com.example.hierarchy";
private static final List<String> BRANCHES = List.of("A", "B", "C");
private TwelveConfigHierarchyTestSupport() {
}
static List<String> concreteConfigNames() {
List<String> names = new ArrayList<>(12);
for (String branch : BRANCHES) {
for (int i = 1; i <= 4; i++) {
names.add("Branch" + branch + i + "SmConfig");
}
}
return names;
}
static void writeSources(Path rootDir) throws IOException {
Files.writeString(rootDir.resolve("RootAbstractSmConfig.java"), rootAbstractSource());
for (String branch : BRANCHES) {
Files.writeString(rootDir.resolve("Branch" + branch + "AbstractSmConfig.java"), branchAbstractSource(branch));
for (int i = 1; i <= 4; i++) {
Files.writeString(rootDir.resolve("Branch" + branch + i + "SmConfig.java"), concreteSource(branch, i));
}
}
}
private static String rootAbstractSource() {
return """
package %s;
public abstract class RootAbstractSmConfig extends StateMachineConfigurerAdapter {
public void configure(StateMachineTransitionConfigurer transitions) throws Exception {
transitions.withExternal().source("ROOT_S1").target("ROOT_S2").event("ROOT_E");
}
}
""".formatted(PKG);
}
private static String branchAbstractSource(String branch) {
String event = branchEvent(branch);
return """
package %s;
public abstract class Branch%sAbstractSmConfig extends RootAbstractSmConfig {
@Override
public void configure(StateMachineTransitionConfigurer transitions) throws Exception {
super.configure(transitions);
transitions.withExternal().source("BR_%s_S1").target("BR_%s_S2").event("%s");
}
}
""".formatted(PKG, branch, branch, branch, event);
}
private static String concreteSource(String branch, int index) {
String event = concreteEvent(branch, index);
String abstractParent = "Branch" + branch + "AbstractSmConfig";
return """
package %s;
public class Branch%s%dSmConfig extends %s {
@Override
public void configure(StateMachineTransitionConfigurer transitions) throws Exception {
super.configure(transitions);
transitions.withExternal().source("C_%s_%d_S1").target("C_%s_%d_S2").event("%s");
}
}
""".formatted(PKG, branch, index, abstractParent, branch, index, branch, index, event);
}
static String branchEvent(String branch) {
return "BRANCH_" + branch + "_E";
}
static String concreteEvent(String branch, int index) {
return "CONCRETE_" + branch + index + "_E";
}
static List<String> expectedEventsForConcrete(String branch, int index) {
return List.of("ROOT_E", branchEvent(branch), concreteEvent(branch, index));
}
}

View File

@@ -0,0 +1,26 @@
plugins {
id 'java'
id 'org.springframework.boot' version '3.5.3'
id 'io.spring.dependency-management' version '1.1.7'
}
group = 'click.kamil'
version = '0.0.1-SNAPSHOT'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.statemachine:spring-statemachine-starter:3.2.0'
}
bootJar { enabled = false }
jar { enabled = true }

View File

@@ -0,0 +1,12 @@
package click.kamil.examples.statemachine.layered;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class LayeredDispatcherApplication {
public static void main(String[] args) {
SpringApplication.run(LayeredDispatcherApplication.class, args);
}
}

View File

@@ -0,0 +1,15 @@
package click.kamil.examples.statemachine.layered.command;
/**
* Intermediate command enum: endpoints map HTTP string keys to these values first,
* then {@link click.kamil.examples.statemachine.layered.dispatch.CentralEventDispatcher}
* maps them to concrete state-machine transition events.
*/
public enum DomainCommand {
ORDER_PAY,
ORDER_SHIP,
ORDER_CANCEL,
DOCUMENT_SUBMIT,
DOCUMENT_APPROVE,
DOCUMENT_REJECT
}

View File

@@ -0,0 +1,83 @@
package click.kamil.examples.statemachine.layered.dispatch;
import click.kamil.examples.statemachine.layered.command.DomainCommand;
import click.kamil.examples.statemachine.layered.document.DocumentState;
import click.kamil.examples.statemachine.layered.document.DocumentTransitionEvent;
import click.kamil.examples.statemachine.layered.order.OrderState;
import click.kamil.examples.statemachine.layered.order.OrderTransitionEvent;
import click.kamil.examples.statemachine.layered.order.PayRichOrderEvent;
import click.kamil.examples.statemachine.layered.order.ShipRichOrderEvent;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.statemachine.StateMachine;
import org.springframework.statemachine.config.StateMachineFactory;
import org.springframework.stereotype.Service;
@Service
public class CentralEventDispatcher {
private final StateMachineFactory<OrderState, OrderTransitionEvent> orderStateMachineFactory;
private final StateMachineFactory<DocumentState, DocumentTransitionEvent> documentStateMachineFactory;
@Autowired
public CentralEventDispatcher(
StateMachineFactory<OrderState, OrderTransitionEvent> orderStateMachineFactory,
StateMachineFactory<DocumentState, DocumentTransitionEvent> documentStateMachineFactory) {
this.orderStateMachineFactory = orderStateMachineFactory;
this.documentStateMachineFactory = documentStateMachineFactory;
}
public void dispatch(DomainCommand command) {
switch (command) {
case ORDER_PAY -> orderPay();
case ORDER_SHIP -> orderShip();
case ORDER_CANCEL -> orderCancel();
case DOCUMENT_SUBMIT -> documentSubmit();
case DOCUMENT_APPROVE -> documentApprove();
case DOCUMENT_REJECT -> documentReject();
}
}
public void orderPay() {
sendOrderEvent(OrderTransitionEvent.PAY);
}
public void orderShip() {
sendOrderEvent(OrderTransitionEvent.SHIP);
}
public void orderCancel() {
sendOrderEvent(OrderTransitionEvent.CANCEL);
}
public void orderPayViaRichEvent(PayRichOrderEvent event) {
sendOrderEvent(event.getType());
}
public void orderShipViaRichEvent(ShipRichOrderEvent event) {
sendOrderEvent(event.getType());
}
public void documentSubmit() {
sendDocumentEvent(DocumentTransitionEvent.SUBMIT);
}
public void documentApprove() {
sendDocumentEvent(DocumentTransitionEvent.APPROVE);
}
public void documentReject() {
sendDocumentEvent(DocumentTransitionEvent.REJECT);
}
private void sendOrderEvent(OrderTransitionEvent event) {
StateMachine<OrderState, OrderTransitionEvent> machine = orderStateMachineFactory.getStateMachine();
machine.start();
machine.sendEvent(event);
}
private void sendDocumentEvent(DocumentTransitionEvent event) {
StateMachine<DocumentState, DocumentTransitionEvent> machine = documentStateMachineFactory.getStateMachine();
machine.start();
machine.sendEvent(event);
}
}

View File

@@ -0,0 +1,23 @@
package click.kamil.examples.statemachine.layered.dispatch;
import click.kamil.examples.statemachine.layered.command.DomainCommand;
import org.springframework.stereotype.Component;
@Component
public class StringCommandMapper {
public DomainCommand fromString(String commandKey) {
if (commandKey == null) {
throw new IllegalArgumentException("commandKey must not be null");
}
return switch (commandKey) {
case "order.pay" -> DomainCommand.ORDER_PAY;
case "order.ship" -> DomainCommand.ORDER_SHIP;
case "order.cancel" -> DomainCommand.ORDER_CANCEL;
case "document.submit" -> DomainCommand.DOCUMENT_SUBMIT;
case "document.approve" -> DomainCommand.DOCUMENT_APPROVE;
case "document.reject" -> DomainCommand.DOCUMENT_REJECT;
default -> throw new IllegalArgumentException("Unknown command: " + commandKey);
};
}
}

View File

@@ -0,0 +1,8 @@
package click.kamil.examples.statemachine.layered.document;
public enum DocumentState {
DRAFT,
SUBMITTED,
APPROVED,
REJECTED
}

View File

@@ -0,0 +1,7 @@
package click.kamil.examples.statemachine.layered.document;
public enum DocumentTransitionEvent {
SUBMIT,
APPROVE,
REJECT
}

View File

@@ -0,0 +1,46 @@
package click.kamil.examples.statemachine.layered.document.config;
import click.kamil.examples.statemachine.layered.document.DocumentState;
import click.kamil.examples.statemachine.layered.document.DocumentTransitionEvent;
import org.springframework.statemachine.config.EnumStateMachineConfigurerAdapter;
import org.springframework.statemachine.config.builders.StateMachineStateConfigurer;
import org.springframework.statemachine.config.builders.StateMachineTransitionConfigurer;
public abstract class AbstractDocumentStateMachineConfiguration
extends EnumStateMachineConfigurerAdapter<DocumentState, DocumentTransitionEvent> {
@Override
public void configure(StateMachineStateConfigurer<DocumentState, DocumentTransitionEvent> states)
throws Exception {
states
.withStates()
.initial(DocumentState.DRAFT)
.state(DocumentState.SUBMITTED)
.state(DocumentState.APPROVED);
configureAdditionalStates(states);
}
@Override
public void configure(StateMachineTransitionConfigurer<DocumentState, DocumentTransitionEvent> transitions)
throws Exception {
transitions
.withExternal()
.source(DocumentState.DRAFT)
.target(DocumentState.SUBMITTED)
.event(DocumentTransitionEvent.SUBMIT)
.and()
.withExternal()
.source(DocumentState.SUBMITTED)
.target(DocumentState.APPROVED)
.event(DocumentTransitionEvent.APPROVE);
configureAdditionalTransitions(transitions);
}
protected void configureAdditionalStates(StateMachineStateConfigurer<DocumentState, DocumentTransitionEvent> states)
throws Exception {
}
protected void configureAdditionalTransitions(
StateMachineTransitionConfigurer<DocumentState, DocumentTransitionEvent> transitions) throws Exception {
}
}

View File

@@ -0,0 +1,29 @@
package click.kamil.examples.statemachine.layered.document.config;
import click.kamil.examples.statemachine.layered.document.DocumentState;
import click.kamil.examples.statemachine.layered.document.DocumentTransitionEvent;
import org.springframework.context.annotation.Configuration;
import org.springframework.statemachine.config.EnableStateMachineFactory;
import org.springframework.statemachine.config.builders.StateMachineStateConfigurer;
import org.springframework.statemachine.config.builders.StateMachineTransitionConfigurer;
@Configuration
@EnableStateMachineFactory(name = "documentStateMachineFactory")
public class StandardDocumentStateMachineConfiguration extends AbstractDocumentStateMachineConfiguration {
@Override
protected void configureAdditionalStates(StateMachineStateConfigurer<DocumentState, DocumentTransitionEvent> states)
throws Exception {
states.state(DocumentState.REJECTED);
}
@Override
protected void configureAdditionalTransitions(
StateMachineTransitionConfigurer<DocumentState, DocumentTransitionEvent> transitions) throws Exception {
transitions
.withExternal()
.source(DocumentState.SUBMITTED)
.target(DocumentState.REJECTED)
.event(DocumentTransitionEvent.REJECT);
}
}

View File

@@ -0,0 +1,8 @@
package click.kamil.examples.statemachine.layered.order;
public enum OrderState {
NEW,
PAID,
SHIPPED,
CANCELLED
}

View File

@@ -0,0 +1,7 @@
package click.kamil.examples.statemachine.layered.order;
public enum OrderTransitionEvent {
PAY,
SHIP,
CANCEL
}

View File

@@ -0,0 +1,8 @@
package click.kamil.examples.statemachine.layered.order;
public class PayRichOrderEvent implements RichOrderEvent {
@Override
public OrderTransitionEvent getType() {
return OrderTransitionEvent.PAY;
}
}

View File

@@ -0,0 +1,7 @@
package click.kamil.examples.statemachine.layered.order;
import click.kamil.examples.statemachine.layered.order.OrderTransitionEvent;
public interface RichOrderEvent {
OrderTransitionEvent getType();
}

View File

@@ -0,0 +1,8 @@
package click.kamil.examples.statemachine.layered.order;
public class ShipRichOrderEvent implements RichOrderEvent {
@Override
public OrderTransitionEvent getType() {
return OrderTransitionEvent.SHIP;
}
}

View File

@@ -0,0 +1,45 @@
package click.kamil.examples.statemachine.layered.order.config;
import click.kamil.examples.statemachine.layered.order.OrderState;
import click.kamil.examples.statemachine.layered.order.OrderTransitionEvent;
import org.springframework.statemachine.config.EnumStateMachineConfigurerAdapter;
import org.springframework.statemachine.config.builders.StateMachineStateConfigurer;
import org.springframework.statemachine.config.builders.StateMachineTransitionConfigurer;
public abstract class AbstractOrderStateMachineConfiguration
extends EnumStateMachineConfigurerAdapter<OrderState, OrderTransitionEvent> {
@Override
public void configure(StateMachineStateConfigurer<OrderState, OrderTransitionEvent> states) throws Exception {
states
.withStates()
.initial(OrderState.NEW)
.state(OrderState.PAID)
.state(OrderState.SHIPPED);
configureAdditionalStates(states);
}
@Override
public void configure(StateMachineTransitionConfigurer<OrderState, OrderTransitionEvent> transitions)
throws Exception {
transitions
.withExternal()
.source(OrderState.NEW)
.target(OrderState.PAID)
.event(OrderTransitionEvent.PAY)
.and()
.withExternal()
.source(OrderState.PAID)
.target(OrderState.SHIPPED)
.event(OrderTransitionEvent.SHIP);
configureAdditionalTransitions(transitions);
}
protected void configureAdditionalStates(StateMachineStateConfigurer<OrderState, OrderTransitionEvent> states)
throws Exception {
}
protected void configureAdditionalTransitions(
StateMachineTransitionConfigurer<OrderState, OrderTransitionEvent> transitions) throws Exception {
}
}

View File

@@ -0,0 +1,29 @@
package click.kamil.examples.statemachine.layered.order.config;
import click.kamil.examples.statemachine.layered.order.OrderState;
import click.kamil.examples.statemachine.layered.order.OrderTransitionEvent;
import org.springframework.context.annotation.Configuration;
import org.springframework.statemachine.config.EnableStateMachineFactory;
import org.springframework.statemachine.config.builders.StateMachineStateConfigurer;
import org.springframework.statemachine.config.builders.StateMachineTransitionConfigurer;
@Configuration
@EnableStateMachineFactory(name = "orderStateMachineFactory")
public class StandardOrderStateMachineConfiguration extends AbstractOrderStateMachineConfiguration {
@Override
protected void configureAdditionalStates(StateMachineStateConfigurer<OrderState, OrderTransitionEvent> states)
throws Exception {
states.state(OrderState.CANCELLED);
}
@Override
protected void configureAdditionalTransitions(
StateMachineTransitionConfigurer<OrderState, OrderTransitionEvent> transitions) throws Exception {
transitions
.withExternal()
.source(OrderState.PAID)
.target(OrderState.CANCELLED)
.event(OrderTransitionEvent.CANCEL);
}
}

View File

@@ -0,0 +1,61 @@
package click.kamil.examples.statemachine.layered.web;
import click.kamil.examples.statemachine.layered.command.DomainCommand;
import click.kamil.examples.statemachine.layered.dispatch.CentralEventDispatcher;
import click.kamil.examples.statemachine.layered.dispatch.StringCommandMapper;
import click.kamil.examples.statemachine.layered.order.PayRichOrderEvent;
import click.kamil.examples.statemachine.layered.order.ShipRichOrderEvent;
import org.springframework.stereotype.Service;
@Service
public class CommandGateway {
private final StringCommandMapper stringCommandMapper;
private final CentralEventDispatcher centralEventDispatcher;
public CommandGateway(StringCommandMapper stringCommandMapper, CentralEventDispatcher centralEventDispatcher) {
this.stringCommandMapper = stringCommandMapper;
this.centralEventDispatcher = centralEventDispatcher;
}
public void payOrder() {
centralEventDispatcher.orderPay();
}
public void shipOrder() {
centralEventDispatcher.orderShip();
}
public void cancelOrder() {
centralEventDispatcher.orderCancel();
}
public void payOrderViaRichEvent(PayRichOrderEvent event) {
centralEventDispatcher.orderPayViaRichEvent(event);
}
public void shipOrderViaRichEvent(ShipRichOrderEvent event) {
centralEventDispatcher.orderShipViaRichEvent(event);
}
public void submitDocument() {
centralEventDispatcher.documentSubmit();
}
public void approveDocument() {
centralEventDispatcher.documentApprove();
}
public void rejectDocument() {
centralEventDispatcher.documentReject();
}
/**
* Full two-hop dispatch used by {@link GenericCommandController}:
* string key → {@link DomainCommand} → transition event.
*/
public void executeViaMapper(String commandKey) {
DomainCommand command = stringCommandMapper.fromString(commandKey);
centralEventDispatcher.dispatch(command);
}
}

View File

@@ -0,0 +1,31 @@
package click.kamil.examples.statemachine.layered.web;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/documents")
public class DocumentController {
private final CommandGateway commandGateway;
public DocumentController(CommandGateway commandGateway) {
this.commandGateway = commandGateway;
}
@PostMapping("/submit")
public void submit() {
commandGateway.submitDocument();
}
@PostMapping("/approve")
public void approve() {
commandGateway.approveDocument();
}
@PostMapping("/reject")
public void reject() {
commandGateway.rejectDocument();
}
}

View File

@@ -0,0 +1,22 @@
package click.kamil.examples.statemachine.layered.web;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/commands")
public class GenericCommandController {
private final CommandGateway commandGateway;
public GenericCommandController(CommandGateway commandGateway) {
this.commandGateway = commandGateway;
}
@PostMapping("/{commandKey}")
public void execute(@PathVariable String commandKey) {
commandGateway.executeViaMapper(commandKey);
}
}

View File

@@ -0,0 +1,31 @@
package click.kamil.examples.statemachine.layered.web;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/orders")
public class OrderController {
private final CommandGateway commandGateway;
public OrderController(CommandGateway commandGateway) {
this.commandGateway = commandGateway;
}
@PostMapping("/pay")
public void pay() {
commandGateway.payOrder();
}
@PostMapping("/ship")
public void ship() {
commandGateway.shipOrder();
}
@PostMapping("/cancel")
public void cancel() {
commandGateway.cancelOrder();
}
}

View File

@@ -0,0 +1,28 @@
package click.kamil.examples.statemachine.layered.web;
import click.kamil.examples.statemachine.layered.order.PayRichOrderEvent;
import click.kamil.examples.statemachine.layered.order.ShipRichOrderEvent;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/orders/rich")
public class RichOrderController {
private final CommandGateway commandGateway;
public RichOrderController(CommandGateway commandGateway) {
this.commandGateway = commandGateway;
}
@PostMapping("/pay")
public void payViaRichEvent() {
commandGateway.payOrderViaRichEvent(new PayRichOrderEvent());
}
@PostMapping("/ship")
public void shipViaRichEvent() {
commandGateway.shipOrderViaRichEvent(new ShipRichOrderEvent());
}
}

View File

@@ -0,0 +1,31 @@
package click.kamil.examples.statemachine.layered.web;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* Demonstrates the full two-hop dispatch chain with string literals:
* string key → {@link click.kamil.examples.statemachine.layered.command.DomainCommand}
* → transition event.
*/
@RestController
@RequestMapping("/api/string-dispatch")
public class StringDispatchController {
private final CommandGateway commandGateway;
public StringDispatchController(CommandGateway commandGateway) {
this.commandGateway = commandGateway;
}
@PostMapping("/orders/pay")
public void payWithStringKey() {
commandGateway.executeViaMapper("order.pay");
}
@PostMapping("/orders/ship")
public void shipWithStringKey() {
commandGateway.executeViaMapper("order.ship");
}
}