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:
@@ -15,6 +15,7 @@ include ':state_machines:inheritance_extra_functions3_state_machine'
|
|||||||
include ':state_machines:simple_state_machine'
|
include ':state_machines:simple_state_machine'
|
||||||
include ':state_machines:extended_analysis_sample'
|
include ':state_machines:extended_analysis_sample'
|
||||||
include ':state_machines:inheritance_sample'
|
include ':state_machines:inheritance_sample'
|
||||||
|
include ':state_machines:layered_dispatcher_sample'
|
||||||
include ':state_machines:ultimate_ecosystem_sm'
|
include ':state_machines:ultimate_ecosystem_sm'
|
||||||
include ':state_machines:enterprise_order_system'
|
include ':state_machines:enterprise_order_system'
|
||||||
include ':state_machines:multi_module_sample:api-module'
|
include ':state_machines:multi_module_sample:api-module'
|
||||||
|
|||||||
@@ -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.routing.BeanResolutionEngine;
|
||||||
import click.kamil.springstatemachineexporter.analysis.enricher.matching.EventMatchingEngine;
|
import click.kamil.springstatemachineexporter.analysis.enricher.matching.EventMatchingEngine;
|
||||||
import click.kamil.springstatemachineexporter.analysis.enricher.matching.StrictFqnMatchingEngine;
|
import click.kamil.springstatemachineexporter.analysis.enricher.matching.StrictFqnMatchingEngine;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.resolver.BooleanConstraintEvaluator;
|
||||||
|
|
||||||
public class TransitionLinkerEnricher implements AnalysisEnricher {
|
public class TransitionLinkerEnricher implements AnalysisEnricher {
|
||||||
|
|
||||||
@@ -109,91 +110,30 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private boolean isConstraintCompatible(String constraint, String machineName) {
|
private boolean isConstraintCompatible(String constraint, String machineName) {
|
||||||
if (constraint == null || machineName == null) return true;
|
if (constraint == null || machineName == null) {
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
return true;
|
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<>();
|
private final java.util.Map<String, String> simplifyCache = new java.util.HashMap<>();
|
||||||
|
|||||||
@@ -13,55 +13,45 @@ public class StrictFqnMatchingEngine implements EventMatchingEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (triggerPoint.isExternal()) {
|
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 smEventRaw = stateMachineEvent.fullIdentifier() != null ? stateMachineEvent.fullIdentifier() : stateMachineEvent.rawName();
|
||||||
String rawTriggerEvent = triggerPoint.getEvent();
|
String rawTriggerEvent = triggerPoint.getEvent();
|
||||||
|
|
||||||
if (rawTriggerEvent != null && rawTriggerEvent.startsWith("<SYMBOLIC: ") && rawTriggerEvent.endsWith(".*>")) {
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
List<String> polyEvents = triggerPoint.getPolymorphicEvents() != null ? triggerPoint.getPolymorphicEvents() : java.util.Collections.emptyList();
|
List<String> polyEvents = triggerPoint.getPolymorphicEvents() != null ? triggerPoint.getPolymorphicEvents() : java.util.Collections.emptyList();
|
||||||
|
|
||||||
for (String pe : polyEvents) {
|
for (String pe : polyEvents) {
|
||||||
if (matchEventNames(pe, smEventRaw, triggerPoint.getEventTypeFqn())) {
|
if (pe.startsWith("<SYMBOLIC: ") && pe.endsWith(".*>")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String eventTypeFqn = triggerPoint.getEventTypeFqn();
|
||||||
|
if (pe.contains(".") && isStringTypeOrPrimitive(eventTypeFqn)) {
|
||||||
|
eventTypeFqn = null;
|
||||||
|
}
|
||||||
|
if (matchEventNames(pe, smEventRaw, eventTypeFqn)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (pe.startsWith("<SYMBOLIC: ") && pe.endsWith(".*>")) {
|
if (!pe.contains(".") && smEventRaw.contains(".")) {
|
||||||
String targetType = pe.substring(11, pe.length() - 3);
|
String smConst = constantName(smEventRaw);
|
||||||
if (smEventRaw.startsWith(targetType + ".")) {
|
if (pe.equals(smConst) && triggerPoint.getEventTypeFqn() != null
|
||||||
return true;
|
&& !isStringTypeOrPrimitive(triggerPoint.getEventTypeFqn())) {
|
||||||
}
|
String smEnumType = smEventRaw.substring(0, smEventRaw.lastIndexOf('.'));
|
||||||
if (smEventRaw.contains(".")) {
|
return enumTypesMatch(triggerPoint.getEventTypeFqn(), smEnumType);
|
||||||
String smType = smEventRaw.substring(0, smEventRaw.lastIndexOf('.'));
|
|
||||||
if (smType.endsWith("." + targetType) || smType.equals(targetType)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rawTriggerEvent != null && rawTriggerEvent.contains(".valueOf(")) {
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,7 +81,7 @@ public class StrictFqnMatchingEngine implements EventMatchingEngine {
|
|||||||
if (!tConst.equals(smConst)) return false;
|
if (!tConst.equals(smConst)) return false;
|
||||||
|
|
||||||
// Prevent matching string/primitive triggers to enum transitions
|
// Prevent matching string/primitive triggers to enum transitions
|
||||||
if (isStringTypeOrPrimitive(eventTypeFqn) && smEvent.contains(".")) {
|
if (isStringTypeOrPrimitive(eventTypeFqn) && smEvent.contains(".") && !triggerEvent.contains(".")) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
package click.kamil.springstatemachineexporter.analysis.resolver;
|
package click.kamil.springstatemachineexporter.analysis.resolver;
|
||||||
|
|
||||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||||
|
import click.kamil.springstatemachineexporter.ast.common.JdtDataFlowModel;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.eclipse.jdt.core.dom.*;
|
import org.eclipse.jdt.core.dom.*;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
@@ -17,6 +19,10 @@ public class ConstantResolver {
|
|||||||
return resolveInternal(expr, context, new HashSet<>());
|
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) {
|
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<>());
|
return evaluateSwitchExpression(se, paramValues, context, new HashSet<>());
|
||||||
}
|
}
|
||||||
@@ -35,6 +41,25 @@ public class ConstantResolver {
|
|||||||
if (expr instanceof ParenthesizedExpression pe) {
|
if (expr instanceof ParenthesizedExpression pe) {
|
||||||
return resolveInternal(pe.getExpression(), context, visited);
|
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 (expr instanceof MethodInvocation mi && (mi.getName().getIdentifier().equals("requireNonNull") || mi.getName().getIdentifier().equals("notNull"))) {
|
||||||
if (!mi.arguments().isEmpty()) {
|
if (!mi.arguments().isEmpty()) {
|
||||||
return resolveInternal((Expression) mi.arguments().get(0), context, visited);
|
return resolveInternal((Expression) mi.arguments().get(0), context, visited);
|
||||||
@@ -76,6 +101,10 @@ public class ConstantResolver {
|
|||||||
return val != null ? val : qn.toString();
|
return val != null ? val : qn.toString();
|
||||||
}
|
}
|
||||||
if (expr instanceof FieldAccess fa) {
|
if (expr instanceof FieldAccess fa) {
|
||||||
|
String staticField = resolveStaticFieldAccess(fa, context, visited);
|
||||||
|
if (staticField != null) {
|
||||||
|
return staticField;
|
||||||
|
}
|
||||||
return resolveManual(fa.getName(), context, visited);
|
return resolveManual(fa.getName(), context, visited);
|
||||||
}
|
}
|
||||||
if (expr instanceof SimpleName sn) {
|
if (expr instanceof SimpleName sn) {
|
||||||
@@ -84,7 +113,26 @@ public class ConstantResolver {
|
|||||||
|
|
||||||
|
|
||||||
if (expr instanceof MethodInvocation mi) {
|
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);
|
String val = resolveInternal(mi.getExpression(), context, visited);
|
||||||
if (val != null) {
|
if (val != null) {
|
||||||
// If it was resolved as a fully qualified enum string like "MyEvents.ORDER_PAID", strip it
|
// 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;
|
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) {
|
private String resolveInfix(InfixExpression expr, CodebaseContext context, Set<String> visited) {
|
||||||
if (expr.getOperator() != InfixExpression.Operator.PLUS) return null;
|
if (expr.getOperator() != InfixExpression.Operator.PLUS) return null;
|
||||||
|
|
||||||
@@ -721,7 +813,18 @@ public class ConstantResolver {
|
|||||||
if (td != null) {
|
if (td != null) {
|
||||||
String fqn = context.getFqn(td);
|
String fqn = context.getFqn(td);
|
||||||
String result = resolveFieldInType(td, sn.getIdentifier(), fqn, context, visited);
|
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
|
// Fallback: Static imports
|
||||||
@@ -742,6 +845,9 @@ public class ConstantResolver {
|
|||||||
|
|
||||||
CompilationUnit contextCu = (CompilationUnit) qn.getRoot();
|
CompilationUnit contextCu = (CompilationUnit) qn.getRoot();
|
||||||
TypeDeclaration td = context.getTypeDeclaration(qualifier, contextCu);
|
TypeDeclaration td = context.getTypeDeclaration(qualifier, contextCu);
|
||||||
|
if (td == null) {
|
||||||
|
td = resolveTypeInContext(qualifier, qn, context);
|
||||||
|
}
|
||||||
|
|
||||||
if (td != null) {
|
if (td != null) {
|
||||||
String fqn = context.getFqn(td);
|
String fqn = context.getFqn(td);
|
||||||
@@ -751,6 +857,67 @@ public class ConstantResolver {
|
|||||||
return null;
|
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) {
|
private String resolveFieldInType(TypeDeclaration td, String fieldName, String typeFqn, CodebaseContext context, Set<String> visited) {
|
||||||
String fieldId = typeFqn + "#" + fieldName;
|
String fieldId = typeFqn + "#" + fieldName;
|
||||||
if (visited.contains(fieldId)) {
|
if (visited.contains(fieldId)) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
|||||||
protected final VariableTracer variableTracer;
|
protected final VariableTracer variableTracer;
|
||||||
protected final ConstantExtractor constantExtractor;
|
protected final ConstantExtractor constantExtractor;
|
||||||
protected final ConstructorAnalyzer constructorAnalyzer;
|
protected final ConstructorAnalyzer constructorAnalyzer;
|
||||||
|
protected final ExpressionAccessClassifier expressionAccessClassifier;
|
||||||
private final Map<String, ASTNode> parsedNodeCache = new HashMap<>();
|
private final Map<String, ASTNode> parsedNodeCache = new HashMap<>();
|
||||||
|
|
||||||
private ASTNode parseExpressionString(String expr) {
|
private ASTNode parseExpressionString(String expr) {
|
||||||
@@ -77,6 +78,9 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
|||||||
this.constantExtractor.setConstructorAnalyzer(this.constructorAnalyzer);
|
this.constantExtractor.setConstructorAnalyzer(this.constructorAnalyzer);
|
||||||
this.constructorAnalyzer.setVariableTracer(this.variableTracer);
|
this.constructorAnalyzer.setVariableTracer(this.variableTracer);
|
||||||
this.constructorAnalyzer.setConstantExtractor(this.constantExtractor);
|
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) {
|
public List<CallChain> findChains(List<EntryPoint> entryPoints, List<TriggerPoint> triggers) {
|
||||||
@@ -156,7 +160,8 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
|||||||
String currentParamName = event;
|
String currentParamName = event;
|
||||||
String resolvedValue = event;
|
String resolvedValue = event;
|
||||||
String methodSuffix = "";
|
String methodSuffix = "";
|
||||||
String[] extractedEntry = extractMethodSuffix(currentParamName, methodSuffix);
|
String scopeMethod = path.isEmpty() ? null : path.get(0);
|
||||||
|
String[] extractedEntry = extractMethodSuffix(currentParamName, methodSuffix, scopeMethod);
|
||||||
currentParamName = extractedEntry[0];
|
currentParamName = extractedEntry[0];
|
||||||
methodSuffix = extractedEntry[1];
|
methodSuffix = extractedEntry[1];
|
||||||
boolean isAmbiguous = false;
|
boolean isAmbiguous = false;
|
||||||
@@ -197,7 +202,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
|||||||
Map<String, String> parameterValues = variableTracer.buildParameterValuesMap(caller, target, callGraph, path, i);
|
Map<String, String> parameterValues = variableTracer.buildParameterValuesMap(caller, target, callGraph, path, i);
|
||||||
String tracedVar = variableTracer.traceLocalVariable(target, currentParamName, parameterValues);
|
String tracedVar = variableTracer.traceLocalVariable(target, currentParamName, parameterValues);
|
||||||
if (tracedVar != null && !tracedVar.equals(currentParamName)) {
|
if (tracedVar != null && !tracedVar.equals(currentParamName)) {
|
||||||
String[] extractedTraced = extractMethodSuffix(tracedVar, methodSuffix);
|
String[] extractedTraced = extractMethodSuffix(tracedVar, methodSuffix, target);
|
||||||
tracedVar = extractedTraced[0];
|
tracedVar = extractedTraced[0];
|
||||||
methodSuffix = extractedTraced[1];
|
methodSuffix = extractedTraced[1];
|
||||||
currentParamName = tracedVar;
|
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.
|
// 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];
|
arg = extractedArg[0];
|
||||||
methodSuffix = extractedArg[1];
|
methodSuffix = extractedArg[1];
|
||||||
currentParamName = arg;
|
currentParamName = arg;
|
||||||
@@ -290,11 +295,67 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String entryMethod = path.get(0);
|
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);
|
int entryParamIndex = typeResolver.getParameterIndex(entryMethod, currentParamName);
|
||||||
if (entryParamIndex < 0) {
|
if (entryParamIndex < 0) {
|
||||||
if (methodSuffix.startsWith(".get") || methodSuffix.equals(".type") || methodSuffix.equals(".event") || methodSuffix.equals(".type()") || methodSuffix.equals(".event()")) {
|
if (methodSuffix.startsWith(".get") || methodSuffix.equals(".type") || methodSuffix.equals(".event") || methodSuffix.equals(".type()") || methodSuffix.equals(".event()")) {
|
||||||
String localMethodName = methodSuffix.substring(1).replace("()", "");
|
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) {
|
if (localSetterExpr != null) {
|
||||||
List<String> setterEvents = new ArrayList<>();
|
List<String> setterEvents = new ArrayList<>();
|
||||||
List<Expression> tracedSetters = variableTracer.traceVariableAll(localSetterExpr);
|
List<Expression> tracedSetters = variableTracer.traceVariableAll(localSetterExpr);
|
||||||
@@ -304,7 +365,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
|||||||
if (!setterEvents.isEmpty()) {
|
if (!setterEvents.isEmpty()) {
|
||||||
log.debug("Early return 1: setterEvents = {}", setterEvents);
|
log.debug("Early return 1: setterEvents = {}", setterEvents);
|
||||||
return TriggerPoint.builder()
|
return TriggerPoint.builder()
|
||||||
.event(tp.getEvent())
|
.event(resolvedValue)
|
||||||
.className(tp.getClassName())
|
.className(tp.getClassName())
|
||||||
.methodName(tp.getMethodName())
|
.methodName(tp.getMethodName())
|
||||||
.sourceFile(tp.getSourceFile())
|
.sourceFile(tp.getSourceFile())
|
||||||
@@ -329,7 +390,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
|||||||
if (getterEvents != null && !getterEvents.isEmpty()) {
|
if (getterEvents != null && !getterEvents.isEmpty()) {
|
||||||
log.debug("Early return 2: getterEvents = {}", getterEvents);
|
log.debug("Early return 2: getterEvents = {}", getterEvents);
|
||||||
return TriggerPoint.builder()
|
return TriggerPoint.builder()
|
||||||
.event(tp.getEvent())
|
.event(resolvedValue)
|
||||||
.className(tp.getClassName())
|
.className(tp.getClassName())
|
||||||
.methodName(tp.getMethodName())
|
.methodName(tp.getMethodName())
|
||||||
.sourceFile(tp.getSourceFile())
|
.sourceFile(tp.getSourceFile())
|
||||||
@@ -348,7 +409,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
|||||||
|
|
||||||
String tracedVar = variableTracer.traceLocalVariable(entryMethod, currentParamName);
|
String tracedVar = variableTracer.traceLocalVariable(entryMethod, currentParamName);
|
||||||
if (tracedVar != null && !tracedVar.equals(currentParamName)) {
|
if (tracedVar != null && !tracedVar.equals(currentParamName)) {
|
||||||
String[] extractedFinalTraced = extractMethodSuffix(tracedVar, methodSuffix);
|
String[] extractedFinalTraced = extractMethodSuffix(tracedVar, methodSuffix, entryMethod);
|
||||||
tracedVar = extractedFinalTraced[0];
|
tracedVar = extractedFinalTraced[0];
|
||||||
methodSuffix = extractedFinalTraced[1];
|
methodSuffix = extractedFinalTraced[1];
|
||||||
currentParamName = tracedVar;
|
currentParamName = tracedVar;
|
||||||
@@ -366,7 +427,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
|||||||
if (!polymorphicEvents.contains(parsed)) polymorphicEvents.add(parsed);
|
if (!polymorphicEvents.contains(parsed)) polymorphicEvents.add(parsed);
|
||||||
}
|
}
|
||||||
return TriggerPoint.builder()
|
return TriggerPoint.builder()
|
||||||
.event(tp.getEvent())
|
.event(resolvedValue)
|
||||||
.className(tp.getClassName())
|
.className(tp.getClassName())
|
||||||
.methodName(tp.getMethodName())
|
.methodName(tp.getMethodName())
|
||||||
.sourceFile(tp.getSourceFile())
|
.sourceFile(tp.getSourceFile())
|
||||||
@@ -394,17 +455,62 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
|||||||
|
|
||||||
if (exprNode instanceof MethodInvocation mi) {
|
if (exprNode instanceof MethodInvocation mi) {
|
||||||
methodName = mi.getName().getIdentifier();
|
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;
|
boolean handledStaticEnum = false;
|
||||||
if (methodName.equals("valueOf") && mi.arguments().size() == 1) {
|
if (methodName.equals("valueOf") && mi.arguments().size() == 1) {
|
||||||
Object arg = mi.arguments().get(0);
|
Object arg = mi.arguments().get(0);
|
||||||
|
String enumTypeName = resolveValueOfEnumTypeName(mi);
|
||||||
if (arg instanceof StringLiteral sl) {
|
if (arg instanceof StringLiteral sl) {
|
||||||
polymorphicEvents.add(sl.getLiteralValue());
|
if (enumTypeName != null) {
|
||||||
|
polymorphicEvents.add(enumTypeName + "." + sl.getLiteralValue());
|
||||||
|
} else {
|
||||||
|
polymorphicEvents.add(sl.getLiteralValue());
|
||||||
|
}
|
||||||
methodName = null;
|
methodName = null;
|
||||||
handledStaticEnum = true;
|
handledStaticEnum = true;
|
||||||
} else if (arg instanceof QualifiedName qn) {
|
} else if (arg instanceof QualifiedName qn) {
|
||||||
polymorphicEvents.add(qn.getName().getIdentifier());
|
if (enumTypeName != null) {
|
||||||
|
polymorphicEvents.add(enumTypeName + "." + qn.getName().getIdentifier());
|
||||||
|
} else {
|
||||||
|
polymorphicEvents.add(qn.getName().getIdentifier());
|
||||||
|
}
|
||||||
methodName = null;
|
methodName = null;
|
||||||
handledStaticEnum = true;
|
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()) {
|
} else if (methodName.equals("name") && mi.arguments().isEmpty()) {
|
||||||
if (mi.getExpression() instanceof QualifiedName qn) {
|
if (mi.getExpression() instanceof QualifiedName qn) {
|
||||||
@@ -444,7 +550,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return TriggerPoint.builder()
|
return TriggerPoint.builder()
|
||||||
.event(tp.getEvent())
|
.event(resolvedValue)
|
||||||
.className(tp.getClassName())
|
.className(tp.getClassName())
|
||||||
.methodName(tp.getMethodName())
|
.methodName(tp.getMethodName())
|
||||||
.sourceFile(tp.getSourceFile())
|
.sourceFile(tp.getSourceFile())
|
||||||
@@ -579,7 +685,8 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
|||||||
|
|
||||||
if (methodName != null) {
|
if (methodName != null) {
|
||||||
if (varName != null && !varName.isEmpty() && Character.isLowerCase(varName.charAt(0)) && !methodName.equals("VariableReference")) {
|
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) {
|
if (localSetterExpr != null) {
|
||||||
List<Expression> tracedSetters = variableTracer.traceVariableAll(localSetterExpr);
|
List<Expression> tracedSetters = variableTracer.traceVariableAll(localSetterExpr);
|
||||||
for (Expression tracedSetter : tracedSetters) {
|
for (Expression tracedSetter : tracedSetters) {
|
||||||
@@ -724,10 +831,14 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
|||||||
if (declaredType != null) {
|
if (declaredType != null) {
|
||||||
List<String> enumValues = context.getEnumValues(declaredType);
|
List<String> enumValues = context.getEnumValues(declaredType);
|
||||||
if (enumValues != null && !enumValues.isEmpty()) {
|
if (enumValues != null && !enumValues.isEmpty()) {
|
||||||
for (String ev : enumValues) {
|
if (!hasConcreteEnumConstants(polymorphicEvents, declaredType, context)
|
||||||
polymorphicEvents.add(constantExtractor.parseEnumSetElement(ev));
|
&& !tp.isExternal()
|
||||||
|
&& !isRuntimeEnumParameter(exprNode instanceof Expression expression ? expression : null)) {
|
||||||
|
for (String ev : enumValues) {
|
||||||
|
polymorphicEvents.add(constantExtractor.parseEnumSetElement(ev));
|
||||||
|
}
|
||||||
|
isAmbiguous = true;
|
||||||
}
|
}
|
||||||
isAmbiguous = true;
|
|
||||||
} else {
|
} else {
|
||||||
List<String> typesToInspect = new ArrayList<>();
|
List<String> typesToInspect = new ArrayList<>();
|
||||||
if ("inline-instantiation".equals(sourceMethod)) {
|
if ("inline-instantiation".equals(sourceMethod)) {
|
||||||
@@ -849,6 +960,11 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
|||||||
polymorphicEvents = unpacked;
|
polymorphicEvents = unpacked;
|
||||||
|
|
||||||
polymorphicEvents = polymorphicEvents.stream().distinct().collect(java.util.stream.Collectors.toList());
|
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()) {
|
if (!resolvedValue.equals(event) || !polymorphicEvents.isEmpty()) {
|
||||||
return TriggerPoint.builder()
|
return TriggerPoint.builder()
|
||||||
@@ -1030,12 +1146,108 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
|||||||
return constructorAnalyzer.findReturnedCIC(className, methodName, context);
|
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) {
|
protected List<String> evaluateGetterOnReceiver(String resolvedValue, String methodSuffix, String entryMethod, List<String> path, Map<String, List<CallEdge>> callGraph) {
|
||||||
ASTNode exprNode = parseExpressionString(resolvedValue);
|
ASTNode exprNode = parseExpressionString(resolvedValue);
|
||||||
while (exprNode instanceof ParenthesizedExpression pe) {
|
while (exprNode instanceof ParenthesizedExpression pe) {
|
||||||
exprNode = pe.getExpression();
|
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) {
|
if (exprNode instanceof SuperMethodInvocation smi) {
|
||||||
String getterName = smi.getName().getIdentifier();
|
String getterName = smi.getName().getIdentifier();
|
||||||
int lastDot = entryMethod.lastIndexOf('.');
|
int lastDot = entryMethod.lastIndexOf('.');
|
||||||
@@ -1055,25 +1267,31 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
|||||||
|
|
||||||
if (receiver instanceof SimpleName sn) {
|
if (receiver instanceof SimpleName sn) {
|
||||||
receiverName = sn.getIdentifier();
|
receiverName = sn.getIdentifier();
|
||||||
receiverType = variableTracer.getVariableDeclaredType(entryMethod, receiverName);
|
receiverType = variableTracer.getVariableDeclaredType(scopeMethod, receiverName);
|
||||||
} else if (receiver instanceof ClassInstanceCreation cic) {
|
} else if (receiver instanceof ClassInstanceCreation cic) {
|
||||||
directCic = cic;
|
directCic = cic;
|
||||||
receiverType = cic.getType().toString();
|
receiverType = cic.getType().toString();
|
||||||
} else if (receiver instanceof MethodInvocation receiverMi) {
|
} else if (receiver instanceof MethodInvocation receiverMi) {
|
||||||
Expression currentMi = receiverMi;
|
GetterChainReceiver chainedReceiver = resolveGetterChainToReceiver(receiverMi, scopeMethod);
|
||||||
while (currentMi instanceof MethodInvocation chainMi) {
|
if (chainedReceiver != null) {
|
||||||
String mName = chainMi.getName().getIdentifier();
|
receiverType = chainedReceiver.typeFqn();
|
||||||
if (mName.equalsIgnoreCase("set" + propName) || mName.equalsIgnoreCase(propName)) {
|
directCic = chainedReceiver.cic();
|
||||||
if (!chainMi.arguments().isEmpty()) {
|
} else {
|
||||||
Expression arg = (Expression) chainMi.arguments().get(0);
|
Expression currentMi = receiverMi;
|
||||||
List<String> builderConstants = new ArrayList<>();
|
while (currentMi instanceof MethodInvocation chainMi) {
|
||||||
constantExtractor.extractConstantsFromExpression(arg, builderConstants);
|
String mName = chainMi.getName().getIdentifier();
|
||||||
if (!builderConstants.isEmpty()) {
|
if (mName.equalsIgnoreCase("set" + propName) || mName.equalsIgnoreCase(propName)) {
|
||||||
return builderConstants;
|
if (!chainMi.arguments().isEmpty()) {
|
||||||
|
Expression arg = (Expression) chainMi.arguments().get(0);
|
||||||
|
List<String> builderConstants = new ArrayList<>();
|
||||||
|
constantExtractor.extractConstantsFromExpression(arg, builderConstants);
|
||||||
|
if (!builderConstants.isEmpty()) {
|
||||||
|
return builderConstants;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
currentMi = chainMi.getExpression();
|
||||||
}
|
}
|
||||||
currentMi = chainMi.getExpression();
|
|
||||||
}
|
}
|
||||||
} else if (receiver instanceof SuperMethodInvocation) {
|
} else if (receiver instanceof SuperMethodInvocation) {
|
||||||
int lastDot = entryMethod.lastIndexOf('.');
|
int lastDot = entryMethod.lastIndexOf('.');
|
||||||
@@ -1123,7 +1341,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
|||||||
if (directCic != null) {
|
if (directCic != null) {
|
||||||
cic = directCic;
|
cic = directCic;
|
||||||
} else {
|
} else {
|
||||||
Expression initExpr = findVariableInitializer(entryMethod, varName);
|
Expression initExpr = findVariableInitializer(scopeMethod, varName);
|
||||||
if (initExpr instanceof MethodInvocation initMi) {
|
if (initExpr instanceof MethodInvocation initMi) {
|
||||||
initExpr = unwrapMethodInvocation(initMi, 0, entryMethod);
|
initExpr = unwrapMethodInvocation(initMi, 0, entryMethod);
|
||||||
}
|
}
|
||||||
@@ -1148,18 +1366,6 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
|||||||
}
|
}
|
||||||
if (getter != null && getter.getBody() != null) {
|
if (getter != null && getter.getBody() != null) {
|
||||||
fieldValues.putAll(constructorAnalyzer.buildFieldValuesFromCIC(td, cic, context, this::evaluateMethodCallInConstructor));
|
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<>());
|
String result = constantResolver.evaluateMethodBodyWithLocals(getter, fieldValues, context, new HashSet<>());
|
||||||
if (result != null) {
|
if (result != null) {
|
||||||
String returnType = getter.getReturnType2() != null ? getter.getReturnType2().toString() : null;
|
String returnType = getter.getReturnType2() != null ? getter.getReturnType2().toString() : null;
|
||||||
@@ -1208,7 +1414,22 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
|||||||
return initExpr[0];
|
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) {
|
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 ")) {
|
if (paramName != null && !paramName.contains(" ") && !paramName.startsWith("new ")) {
|
||||||
int bracketIndex = paramName.indexOf('[');
|
int bracketIndex = paramName.indexOf('[');
|
||||||
int dotIndex = -1;
|
int dotIndex = -1;
|
||||||
@@ -1591,12 +1812,22 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
|||||||
return allResolved;
|
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) {
|
private String unwrapConverterMethods(String arg) {
|
||||||
if (arg == null) return null;
|
if (arg == null) return null;
|
||||||
String current = arg;
|
String current = arg;
|
||||||
while (current.contains("(") && !current.startsWith("new ")) {
|
while (current.contains("(") && !current.startsWith("new ")) {
|
||||||
org.eclipse.jdt.core.dom.ASTNode argNode = parseExpressionString(current);
|
org.eclipse.jdt.core.dom.ASTNode argNode = parseExpressionString(current);
|
||||||
if (argNode instanceof org.eclipse.jdt.core.dom.MethodInvocation miArg && !miArg.arguments().isEmpty()) {
|
if (argNode instanceof org.eclipse.jdt.core.dom.MethodInvocation miArg && !miArg.arguments().isEmpty()) {
|
||||||
|
if (expressionAccessClassifier.isKeyedLookup(miArg, null)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
boolean shouldUnpack = false;
|
boolean shouldUnpack = false;
|
||||||
org.eclipse.jdt.core.dom.Expression recv = miArg.getExpression();
|
org.eclipse.jdt.core.dom.Expression recv = miArg.getExpression();
|
||||||
if (recv == null || recv instanceof org.eclipse.jdt.core.dom.ThisExpression) {
|
if (recv == null || recv instanceof org.eclipse.jdt.core.dom.ThisExpression) {
|
||||||
@@ -1619,4 +1850,118 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
|||||||
}
|
}
|
||||||
return current;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -14,6 +14,7 @@ public class ConstantExtractor {
|
|||||||
private final Function<MethodInvocation, String> calledMethodResolver;
|
private final Function<MethodInvocation, String> calledMethodResolver;
|
||||||
private VariableTracer variableTracer;
|
private VariableTracer variableTracer;
|
||||||
private ConstructorAnalyzer constructorAnalyzer;
|
private ConstructorAnalyzer constructorAnalyzer;
|
||||||
|
private ExpressionAccessClassifier expressionAccessClassifier;
|
||||||
|
|
||||||
public ConstantExtractor(CodebaseContext context, ConstantResolver constantResolver, Function<MethodInvocation, String> calledMethodResolver) {
|
public ConstantExtractor(CodebaseContext context, ConstantResolver constantResolver, Function<MethodInvocation, String> calledMethodResolver) {
|
||||||
this.context = context;
|
this.context = context;
|
||||||
@@ -29,25 +30,20 @@ public class ConstantExtractor {
|
|||||||
this.constructorAnalyzer = constructorAnalyzer;
|
this.constructorAnalyzer = constructorAnalyzer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void setExpressionAccessClassifier(ExpressionAccessClassifier expressionAccessClassifier) {
|
||||||
|
this.expressionAccessClassifier = expressionAccessClassifier;
|
||||||
|
}
|
||||||
|
|
||||||
public void extractConstantsFromExpression(Expression expr, List<String> constants) {
|
public void extractConstantsFromExpression(Expression expr, List<String> constants) {
|
||||||
if (constantResolver != null) {
|
if (constantResolver != null) {
|
||||||
String resolved = constantResolver.resolve(expr, context);
|
String resolved = constantResolver.resolve(expr, context);
|
||||||
if (resolved != null) {
|
if (resolved != null) {
|
||||||
if (resolved.startsWith("ENUM_SET:")) {
|
addResolvedConstant(resolved, constants);
|
||||||
for (String eVal : resolved.substring(9).split(",")) {
|
|
||||||
String parsed = parseEnumSetElement(eVal);
|
|
||||||
if (!constants.contains(parsed)) constants.add(parsed);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
constants.add(resolved);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (expr instanceof QualifiedName qn) {
|
if (expr instanceof StringLiteral sl) {
|
||||||
constants.add(qn.toString());
|
|
||||||
} else if (expr instanceof StringLiteral sl) {
|
|
||||||
constants.add(sl.getLiteralValue());
|
constants.add(sl.getLiteralValue());
|
||||||
} else if (expr instanceof ConditionalExpression ce) {
|
} else if (expr instanceof ConditionalExpression ce) {
|
||||||
extractConstantsFromExpression(ce.getThenExpression(), constants);
|
extractConstantsFromExpression(ce.getThenExpression(), constants);
|
||||||
@@ -90,18 +86,15 @@ public class ConstantExtractor {
|
|||||||
String methodName = mi.getName().getIdentifier();
|
String methodName = mi.getName().getIdentifier();
|
||||||
|
|
||||||
log.trace("Processing mi: {}", mi);
|
log.trace("Processing mi: {}", mi);
|
||||||
if (methodName.equals("get") || methodName.equals("getOrDefault")) {
|
if (("get".equals(methodName) || "getOrDefault".equals(methodName)) && mi.getExpression() != null) {
|
||||||
if (mi.getExpression() != null) {
|
if (isKeyedLookupMethod(mi)) {
|
||||||
if (variableTracer != null) {
|
extractMapReceiverConstants(mi.getExpression(), constants);
|
||||||
List<Expression> traced = variableTracer.traceVariableAll(mi.getExpression());
|
return;
|
||||||
for (Expression t : traced) {
|
}
|
||||||
extractConstantsFromExpression(t, constants);
|
if ("get".equals(methodName) && mi.arguments().isEmpty()) {
|
||||||
}
|
extractMapReceiverConstants(mi.getExpression(), constants);
|
||||||
} else {
|
return;
|
||||||
extractConstantsFromExpression(mi.getExpression(), constants);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
if (methodName.equals("of") || methodName.equals("ofEntries") || methodName.equals("asList") || methodName.equals("entry") ||
|
if (methodName.equals("of") || methodName.equals("ofEntries") || methodName.equals("asList") || methodName.equals("entry") ||
|
||||||
methodName.equals("just") || methodName.equals("withPayload") || methodName.equals("success")) {
|
methodName.equals("just") || methodName.equals("withPayload") || methodName.equals("success")) {
|
||||||
@@ -402,6 +395,84 @@ public class ConstantExtractor {
|
|||||||
return eVal.contains(".") ? eVal.substring(eVal.lastIndexOf('.', eVal.lastIndexOf('.') - 1) + 1) : eVal;
|
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) {
|
private Block findEnclosingBlock(ASTNode node) {
|
||||||
ASTNode parent = node.getParent();
|
ASTNode parent = node.getParent();
|
||||||
while (parent != null && !(parent instanceof Block)) {
|
while (parent != null && !(parent instanceof Block)) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,10 @@ public class JdtDataFlowModel implements DataFlowModel {
|
|||||||
CURRENT_PATH.set(path);
|
CURRENT_PATH.set(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static List<String> getCurrentPath() {
|
||||||
|
return CURRENT_PATH.get();
|
||||||
|
}
|
||||||
|
|
||||||
public static void clearCurrentPath() {
|
public static void clearCurrentPath() {
|
||||||
CURRENT_PATH.remove();
|
CURRENT_PATH.remove();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
@Option(names = {"--debug"}, description = "Enable diagnostic mode to show unresolved call chains and detailed analysis logging.", defaultValue = "false")
|
||||||
private boolean debug;
|
private boolean debug;
|
||||||
|
|
||||||
@Option(names = {"--resolve-classpath"}, description = "Automatically run Maven/Gradle to resolve full classpath dependencies for perfect AST bindings.", defaultValue = "false")
|
@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;
|
private boolean resolveClasspath = true;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Integer call() throws Exception {
|
public Integer call() throws Exception {
|
||||||
|
|||||||
@@ -17,6 +17,57 @@ class TransitionLinkerEnricherTest {
|
|||||||
|
|
||||||
private final TransitionLinkerEnricher enricher = new TransitionLinkerEnricher();
|
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
|
@Test
|
||||||
void shouldLinkTransitionByEventOnly() {
|
void shouldLinkTransitionByEventOnly() {
|
||||||
Transition t1 = new Transition();
|
Transition t1 = new Transition();
|
||||||
@@ -543,4 +594,30 @@ class TransitionLinkerEnricherTest {
|
|||||||
enricher.enrich(resultUser, null, null);
|
enricher.enrich(resultUser, null, null);
|
||||||
assertThat(resultUser.getMetadata().getCallChains().get(0).getMatchedTransitions()).isNullOrEmpty();
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -142,14 +142,37 @@ class StrictFqnMatchingEngineTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@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");
|
Event smEvent = Event.of("PAY", "com.example.OrderEvents.PAY");
|
||||||
TriggerPoint triggerPoint = TriggerPoint.builder()
|
TriggerPoint triggerPoint = TriggerPoint.builder()
|
||||||
.event("OrderEvents.valueOf(str)")
|
.event("OrderEvents.valueOf(str)")
|
||||||
.eventTypeFqn("com.example.OrderEvents")
|
.eventTypeFqn("com.example.OrderEvents")
|
||||||
.build();
|
.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
|
@Test
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package click.kamil.springstatemachineexporter.analysis.resolver;
|
package click.kamil.springstatemachineexporter.analysis.resolver;
|
||||||
|
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.service.ConstantExtractor;
|
||||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||||
import org.eclipse.jdt.core.dom.*;
|
import org.eclipse.jdt.core.dom.*;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
@@ -8,6 +9,8 @@ import org.junit.jupiter.api.io.TempDir;
|
|||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
@@ -524,4 +527,166 @@ public class ConstantResolverTest {
|
|||||||
String result2 = resolver.resolve(mi2, context);
|
String result2 = resolver.resolve(mi2, context);
|
||||||
assertThat(result2).isEqualTo("com.example.OrderEvent.SHIPPED");
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -269,6 +269,7 @@ class EnterpriseBugsTest {
|
|||||||
|
|
||||||
TriggerPoint triggerPoint = TriggerPoint.builder()
|
TriggerPoint triggerPoint = TriggerPoint.builder()
|
||||||
.event("OrderEvents.valueOf(someVar)")
|
.event("OrderEvents.valueOf(someVar)")
|
||||||
|
.polymorphicEvents(List.of("OrderEvents.PAY"))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
assertThat(engine.matches(smEvent, triggerPoint)).isTrue();
|
assertThat(engine.matches(smEvent, triggerPoint)).isTrue();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -420,8 +420,8 @@ class HeuristicCallGraphEngineCoreTest {
|
|||||||
|
|
||||||
assertThat(chains).hasSize(1);
|
assertThat(chains).hasSize(1);
|
||||||
CallChain chain = chains.get(0);
|
CallChain chain = chains.get(0);
|
||||||
// It might not fully resolve chained method calls, but it must not crash!
|
assertThat(chain.getTriggerPoint().getPolymorphicEvents())
|
||||||
assertThat(chain.getTriggerPoint().getEvent()).isEqualTo("wrapper.getEvent().getType()");
|
.containsExactlyInAnyOrder("OrderEvents.PAY");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
26
state_machines/layered_dispatcher_sample/build.gradle
Normal file
26
state_machines/layered_dispatcher_sample/build.gradle
Normal 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 }
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package click.kamil.examples.statemachine.layered.document;
|
||||||
|
|
||||||
|
public enum DocumentState {
|
||||||
|
DRAFT,
|
||||||
|
SUBMITTED,
|
||||||
|
APPROVED,
|
||||||
|
REJECTED
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package click.kamil.examples.statemachine.layered.document;
|
||||||
|
|
||||||
|
public enum DocumentTransitionEvent {
|
||||||
|
SUBMIT,
|
||||||
|
APPROVE,
|
||||||
|
REJECT
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package click.kamil.examples.statemachine.layered.order;
|
||||||
|
|
||||||
|
public enum OrderState {
|
||||||
|
NEW,
|
||||||
|
PAID,
|
||||||
|
SHIPPED,
|
||||||
|
CANCELLED
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package click.kamil.examples.statemachine.layered.order;
|
||||||
|
|
||||||
|
public enum OrderTransitionEvent {
|
||||||
|
PAY,
|
||||||
|
SHIP,
|
||||||
|
CANCEL
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package click.kamil.examples.statemachine.layered.order;
|
||||||
|
|
||||||
|
public class PayRichOrderEvent implements RichOrderEvent {
|
||||||
|
@Override
|
||||||
|
public OrderTransitionEvent getType() {
|
||||||
|
return OrderTransitionEvent.PAY;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package click.kamil.examples.statemachine.layered.order;
|
||||||
|
|
||||||
|
import click.kamil.examples.statemachine.layered.order.OrderTransitionEvent;
|
||||||
|
|
||||||
|
public interface RichOrderEvent {
|
||||||
|
OrderTransitionEvent getType();
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package click.kamil.examples.statemachine.layered.order;
|
||||||
|
|
||||||
|
public class ShipRichOrderEvent implements RichOrderEvent {
|
||||||
|
@Override
|
||||||
|
public OrderTransitionEvent getType() {
|
||||||
|
return OrderTransitionEvent.SHIP;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user