From c447641800716964544fd136c545b16d449a2306 Mon Sep 17 00:00:00 2001 From: Kamil Patryk Kozakowski Date: Sat, 11 Jul 2026 10:14:23 +0200 Subject: [PATCH] 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 --- settings.gradle | 1 + .../enricher/TransitionLinkerEnricher.java | 106 +--- .../matching/StrictFqnMatchingEngine.java | 52 +- .../resolver/BooleanConstraintEvaluator.java | 127 +++++ .../analysis/resolver/ConstantResolver.java | 171 ++++++- .../resolver/LiteralExpressionSupport.java | 304 +++++++++++ .../service/AbstractCallGraphEngine.java | 425 ++++++++++++++-- .../analysis/service/ConstantExtractor.java | 115 ++++- .../service/ExpressionAccessClassifier.java | 271 ++++++++++ .../ast/common/JdtDataFlowModel.java | 4 + .../command/ExporterCommand.java | 4 +- .../TransitionLinkerEnricherTest.java | 77 +++ .../matching/StrictFqnMatchingEngineTest.java | 27 +- .../BooleanConstraintEvaluatorTest.java | 66 +++ .../resolver/ConstantResolverTest.java | 165 ++++++ .../service/AnalysisCacheCorrectnessTest.java | 339 +++++++++++++ .../service/CallGraphExpressionShapeTest.java | 94 ++++ .../CentralDispatcherResolutionTest.java | 420 ++++++++++++++++ .../service/CentralDispatcherTestSupport.java | 91 ++++ .../service/DispatcherEndpointTest.java | 136 +++++ .../analysis/service/EnterpriseBugsTest.java | 1 + .../ExpressionAccessClassifierTest.java | 119 +++++ .../HeuristicCallGraphEngineCoreTest.java | 4 +- .../service/LayeredDispatcherSampleTest.java | 103 ++++ .../LiteralDataFlowResolutionTest.java | 470 ++++++++++++++++++ .../TwelveConfigHierarchyTestSupport.java | 101 ++++ .../layered_dispatcher_sample/build.gradle | 26 + .../layered/LayeredDispatcherApplication.java | 12 + .../layered/command/DomainCommand.java | 15 + .../dispatch/CentralEventDispatcher.java | 83 ++++ .../layered/dispatch/StringCommandMapper.java | 23 + .../layered/document/DocumentState.java | 8 + .../document/DocumentTransitionEvent.java | 7 + ...ractDocumentStateMachineConfiguration.java | 46 ++ ...dardDocumentStateMachineConfiguration.java | 29 ++ .../layered/order/OrderState.java | 8 + .../layered/order/OrderTransitionEvent.java | 7 + .../layered/order/PayRichOrderEvent.java | 8 + .../layered/order/RichOrderEvent.java | 7 + .../layered/order/ShipRichOrderEvent.java | 8 + ...bstractOrderStateMachineConfiguration.java | 45 ++ ...tandardOrderStateMachineConfiguration.java | 29 ++ .../layered/web/CommandGateway.java | 61 +++ .../layered/web/DocumentController.java | 31 ++ .../layered/web/GenericCommandController.java | 22 + .../layered/web/OrderController.java | 31 ++ .../layered/web/RichOrderController.java | 28 ++ .../layered/web/StringDispatchController.java | 31 ++ 48 files changed, 4174 insertions(+), 184 deletions(-) create mode 100644 state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/BooleanConstraintEvaluator.java create mode 100644 state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/LiteralExpressionSupport.java create mode 100644 state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/ExpressionAccessClassifier.java create mode 100644 state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/BooleanConstraintEvaluatorTest.java create mode 100644 state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/AnalysisCacheCorrectnessTest.java create mode 100644 state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/CallGraphExpressionShapeTest.java create mode 100644 state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/CentralDispatcherResolutionTest.java create mode 100644 state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/CentralDispatcherTestSupport.java create mode 100644 state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/DispatcherEndpointTest.java create mode 100644 state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/ExpressionAccessClassifierTest.java create mode 100644 state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/LayeredDispatcherSampleTest.java create mode 100644 state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/LiteralDataFlowResolutionTest.java create mode 100644 state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/TwelveConfigHierarchyTestSupport.java create mode 100644 state_machines/layered_dispatcher_sample/build.gradle create mode 100644 state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/LayeredDispatcherApplication.java create mode 100644 state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/command/DomainCommand.java create mode 100644 state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/dispatch/CentralEventDispatcher.java create mode 100644 state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/dispatch/StringCommandMapper.java create mode 100644 state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/document/DocumentState.java create mode 100644 state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/document/DocumentTransitionEvent.java create mode 100644 state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/document/config/AbstractDocumentStateMachineConfiguration.java create mode 100644 state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/document/config/StandardDocumentStateMachineConfiguration.java create mode 100644 state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/order/OrderState.java create mode 100644 state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/order/OrderTransitionEvent.java create mode 100644 state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/order/PayRichOrderEvent.java create mode 100644 state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/order/RichOrderEvent.java create mode 100644 state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/order/ShipRichOrderEvent.java create mode 100644 state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/order/config/AbstractOrderStateMachineConfiguration.java create mode 100644 state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/order/config/StandardOrderStateMachineConfiguration.java create mode 100644 state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/web/CommandGateway.java create mode 100644 state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/web/DocumentController.java create mode 100644 state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/web/GenericCommandController.java create mode 100644 state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/web/OrderController.java create mode 100644 state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/web/RichOrderController.java create mode 100644 state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/web/StringDispatchController.java diff --git a/settings.gradle b/settings.gradle index 212252e..bd1dff4 100644 --- a/settings.gradle +++ b/settings.gradle @@ -15,6 +15,7 @@ include ':state_machines:inheritance_extra_functions3_state_machine' include ':state_machines:simple_state_machine' include ':state_machines:extended_analysis_sample' include ':state_machines:inheritance_sample' +include ':state_machines:layered_dispatcher_sample' include ':state_machines:ultimate_ecosystem_sm' include ':state_machines:enterprise_order_system' include ':state_machines:multi_module_sample:api-module' diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/TransitionLinkerEnricher.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/TransitionLinkerEnricher.java index b4fa03c..f94d33a 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/TransitionLinkerEnricher.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/TransitionLinkerEnricher.java @@ -19,6 +19,7 @@ import click.kamil.springstatemachineexporter.analysis.enricher.routing.Heuristi import click.kamil.springstatemachineexporter.analysis.enricher.routing.BeanResolutionEngine; import click.kamil.springstatemachineexporter.analysis.enricher.matching.EventMatchingEngine; import click.kamil.springstatemachineexporter.analysis.enricher.matching.StrictFqnMatchingEngine; +import click.kamil.springstatemachineexporter.analysis.resolver.BooleanConstraintEvaluator; public class TransitionLinkerEnricher implements AnalysisEnricher { @@ -109,91 +110,30 @@ public class TransitionLinkerEnricher implements AnalysisEnricher { } private boolean isConstraintCompatible(String constraint, String machineName) { - if (constraint == null || machineName == null) return true; - - String cleanMachine = machineName.substring(machineName.lastIndexOf('.') + 1).toUpperCase(); - if (cleanMachine.endsWith("STATEMACHINECONFIGURATION")) { - cleanMachine = cleanMachine.substring(0, cleanMachine.length() - "STATEMACHINECONFIGURATION".length()); - } - if (cleanMachine.endsWith("STATEMACHINE")) { - cleanMachine = cleanMachine.substring(0, cleanMachine.length() - "STATEMACHINE".length()); - } - if (cleanMachine.endsWith("CONFIG")) { - cleanMachine = cleanMachine.substring(0, cleanMachine.length() - "CONFIG".length()); - } - - String expr = constraint; - java.util.regex.Pattern p = java.util.regex.Pattern.compile("[\"']([a-zA-Z0-9_-]+)[\"']"); - java.util.regex.Matcher matcher = p.matcher(constraint); - java.util.Set literals = new java.util.HashSet<>(); - while (matcher.find()) { - literals.add(matcher.group(1)); - } - - for (String m : literals) { - boolean isCurrent = m.equalsIgnoreCase(cleanMachine); - String replacement = isCurrent ? "true" : "false"; - - String regex1 = "(?i)[\"']?" + m + "[\"']?\\s*\\.\\s*(equalsIgnoreCase|equals)\\s*\\([^)]*\\)"; - String regex2 = "(?i)[a-zA-Z0-9._]+\\s*\\.\\s*(equalsIgnoreCase|equals)\\s*\\(\\s*[\"']?" + m + "[\"']?\\s*\\)"; - String regex3 = "(?i)[a-zA-Z0-9._]+\\s*==\\s*[\"']?" + m + "[\"']?"; - String regex4 = "(?i)[a-zA-Z0-9._]+\\s*!=\\s*[\"']?" + m + "[\"']?"; - - expr = expr.replaceAll(regex1, replacement); - expr = expr.replaceAll(regex2, replacement); - expr = expr.replaceAll(regex3, replacement); - expr = expr.replaceAll(regex4, isCurrent ? "false" : "true"); - } - - try { - return BooleanEvaluator.evaluate(expr); - } catch (Exception e) { - return !expr.contains("false"); - } - } - - private static class BooleanEvaluator { - public static boolean evaluate(String expr) { - expr = expr.replaceAll("\\s+", ""); - return parseExpr(expr); - } - - private static boolean parseExpr(String s) { - if (s.isEmpty()) return true; - - int parenDepth = 0; - for (int i = s.length() - 1; i >= 0; i--) { - char c = s.charAt(i); - if (c == ')') parenDepth++; - else if (c == '(') parenDepth--; - else if (parenDepth == 0 && i > 0 && s.charAt(i) == '|' && s.charAt(i - 1) == '|') { - return parseExpr(s.substring(0, i - 1)) || parseExpr(s.substring(i + 1)); - } - } - - parenDepth = 0; - for (int i = s.length() - 1; i >= 0; i--) { - char c = s.charAt(i); - if (c == ')') parenDepth++; - else if (c == '(') parenDepth--; - else if (parenDepth == 0 && i > 0 && s.charAt(i) == '&' && s.charAt(i - 1) == '&') { - return parseExpr(s.substring(0, i - 1)) && parseExpr(s.substring(i + 1)); - } - } - - if (s.startsWith("!")) { - return !parseExpr(s.substring(1)); - } - - if (s.startsWith("(") && s.endsWith(")")) { - return parseExpr(s.substring(1, s.length() - 1)); - } - - if (s.equalsIgnoreCase("true")) return true; - if (s.equalsIgnoreCase("false")) return false; - + if (constraint == null || machineName == null) { return true; } + return BooleanConstraintEvaluator.isCompatibleWithMachineDomain( + constraint, extractMachineDomainKey(machineName)); + } + + private String extractMachineDomainKey(String machineName) { + String simple = machineName.substring(machineName.lastIndexOf('.') + 1); + String upper = simple.toUpperCase(); + + for (String marker : List.of("STATEMACHINECONFIGURATION", "STATEMACHINE", "CONFIGURATION", "CONFIG")) { + int idx = upper.indexOf(marker); + if (idx > 0) { + return upper.substring(0, idx); + } + } + + int stateIdx = upper.indexOf("STATE"); + if (stateIdx > 0) { + return upper.substring(0, stateIdx); + } + + return upper; } private final java.util.Map simplifyCache = new java.util.HashMap<>(); diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/matching/StrictFqnMatchingEngine.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/matching/StrictFqnMatchingEngine.java index d89598d..dfe7298 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/matching/StrictFqnMatchingEngine.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/matching/StrictFqnMatchingEngine.java @@ -13,55 +13,45 @@ public class StrictFqnMatchingEngine implements EventMatchingEngine { } if (triggerPoint.isExternal()) { - return true; + List polyEvents = triggerPoint.getPolymorphicEvents() != null + ? triggerPoint.getPolymorphicEvents() + : java.util.Collections.emptyList(); + if (polyEvents.isEmpty()) { + return false; + } } String smEventRaw = stateMachineEvent.fullIdentifier() != null ? stateMachineEvent.fullIdentifier() : stateMachineEvent.rawName(); String rawTriggerEvent = triggerPoint.getEvent(); if (rawTriggerEvent != null && rawTriggerEvent.startsWith("")) { - String targetType = rawTriggerEvent.substring(11, rawTriggerEvent.length() - 3); - if (smEventRaw.startsWith(targetType + ".")) { - return true; - } - if (smEventRaw.contains(".")) { - String smType = smEventRaw.substring(0, smEventRaw.lastIndexOf('.')); - if (smType.endsWith("." + targetType) || smType.equals(targetType)) { - return true; - } - } return false; } List polyEvents = triggerPoint.getPolymorphicEvents() != null ? triggerPoint.getPolymorphicEvents() : java.util.Collections.emptyList(); for (String pe : polyEvents) { - if (matchEventNames(pe, smEventRaw, triggerPoint.getEventTypeFqn())) { + if (pe.startsWith("")) { + continue; + } + String eventTypeFqn = triggerPoint.getEventTypeFqn(); + if (pe.contains(".") && isStringTypeOrPrimitive(eventTypeFqn)) { + eventTypeFqn = null; + } + if (matchEventNames(pe, smEventRaw, eventTypeFqn)) { return true; } - if (pe.startsWith("")) { - String targetType = pe.substring(11, pe.length() - 3); - if (smEventRaw.startsWith(targetType + ".")) { - return true; - } - if (smEventRaw.contains(".")) { - String smType = smEventRaw.substring(0, smEventRaw.lastIndexOf('.')); - if (smType.endsWith("." + targetType) || smType.equals(targetType)) { - return true; - } + if (!pe.contains(".") && smEventRaw.contains(".")) { + String smConst = constantName(smEventRaw); + if (pe.equals(smConst) && triggerPoint.getEventTypeFqn() != null + && !isStringTypeOrPrimitive(triggerPoint.getEventTypeFqn())) { + String smEnumType = smEventRaw.substring(0, smEventRaw.lastIndexOf('.')); + return enumTypesMatch(triggerPoint.getEventTypeFqn(), smEnumType); } } } if (rawTriggerEvent != null && rawTriggerEvent.contains(".valueOf(")) { - String enumClass = rawTriggerEvent.substring(0, rawTriggerEvent.indexOf(".valueOf(")); - if (enumClass.contains(" ")) { - enumClass = enumClass.substring(enumClass.lastIndexOf(" ") + 1); - } - String smClass = smEventRaw.contains(".") ? smEventRaw.substring(0, smEventRaw.lastIndexOf('.')) : smEventRaw; - if (enumClass.equals(smClass) || enumClass.endsWith("." + smClass) || smClass.endsWith("." + enumClass)) { - return true; - } return false; } @@ -91,7 +81,7 @@ public class StrictFqnMatchingEngine implements EventMatchingEngine { if (!tConst.equals(smConst)) return false; // Prevent matching string/primitive triggers to enum transitions - if (isStringTypeOrPrimitive(eventTypeFqn) && smEvent.contains(".")) { + if (isStringTypeOrPrimitive(eventTypeFqn) && smEvent.contains(".") && !triggerEvent.contains(".")) { return false; } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/BooleanConstraintEvaluator.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/BooleanConstraintEvaluator.java new file mode 100644 index 0000000..a8cf3b1 --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/BooleanConstraintEvaluator.java @@ -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 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 extractStringLiterals(String constraint) { + Set 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; + } +} diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/ConstantResolver.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/ConstantResolver.java index 71f1735..9c251e4 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/ConstantResolver.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/ConstantResolver.java @@ -1,9 +1,11 @@ package click.kamil.springstatemachineexporter.analysis.resolver; import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import click.kamil.springstatemachineexporter.ast.common.JdtDataFlowModel; import lombok.extern.slf4j.Slf4j; import org.eclipse.jdt.core.dom.*; +import java.util.ArrayList; import java.util.HashSet; import java.util.Set; import java.util.LinkedHashSet; @@ -17,6 +19,10 @@ public class ConstantResolver { return resolveInternal(expr, context, new HashSet<>()); } + public static List decodeMapLiteralValues(String encoded) { + return LiteralExpressionSupport.mapLiteralValues(encoded); + } + public String evaluateSwitchWithParams(org.eclipse.jdt.core.dom.SwitchExpression se, java.util.Map paramValues, CodebaseContext context) { return evaluateSwitchExpression(se, paramValues, context, new HashSet<>()); } @@ -35,6 +41,25 @@ public class ConstantResolver { if (expr instanceof ParenthesizedExpression pe) { return resolveInternal(pe.getExpression(), context, visited); } + if (expr instanceof ArrayAccess access) { + String indexed = LiteralExpressionSupport.resolveArrayAccess( + access, context, visited, (e, v) -> resolveInternal(e, context, v)); + if (indexed != null) { + return indexed; + } + } + if (expr instanceof ArrayCreation creation && creation.getInitializer() != null) { + List elements = extractArrayElementsFromCreation(creation, context, visited); + if (elements != null) { + return LiteralExpressionSupport.encodeArrayLiteral(elements); + } + } + if (expr instanceof ArrayInitializer initializer) { + List elements = extractArrayElementsFromInitializer(initializer, context, visited); + if (elements != null) { + return LiteralExpressionSupport.encodeArrayLiteral(elements); + } + } if (expr instanceof MethodInvocation mi && (mi.getName().getIdentifier().equals("requireNonNull") || mi.getName().getIdentifier().equals("notNull"))) { if (!mi.arguments().isEmpty()) { return resolveInternal((Expression) mi.arguments().get(0), context, visited); @@ -76,6 +101,10 @@ public class ConstantResolver { return val != null ? val : qn.toString(); } if (expr instanceof FieldAccess fa) { + String staticField = resolveStaticFieldAccess(fa, context, visited); + if (staticField != null) { + return staticField; + } return resolveManual(fa.getName(), context, visited); } if (expr instanceof SimpleName sn) { @@ -84,7 +113,26 @@ public class ConstantResolver { if (expr instanceof MethodInvocation mi) { - if ((mi.getName().getIdentifier().equals("name") || mi.getName().getIdentifier().equals("toString")) && mi.arguments().isEmpty()) { + String methodName = mi.getName().getIdentifier(); + if ("get".equals(methodName) || "getOrDefault".equals(methodName)) { + String mapValue = LiteralExpressionSupport.resolveMapGet( + mi, context, visited, (e, v) -> resolveInternal(e, context, v)); + if (mapValue != null) { + return mapValue; + } + } + String transformed = LiteralExpressionSupport.resolveLiteralStringTransform( + mi, visited, (e, v) -> resolveInternal(e, context, v)); + if (transformed != null) { + return transformed; + } + java.util.Map mapEntries = LiteralExpressionSupport.extractMapEntries( + mi, context, visited, (e, v) -> resolveInternal(e, context, v)); + if (mapEntries != null && isMapFactoryInvocation(mi)) { + return LiteralExpressionSupport.encodeMapLiteral(mapEntries); + } + + if ((methodName.equals("name") || methodName.equals("toString")) && mi.arguments().isEmpty()) { String val = resolveInternal(mi.getExpression(), context, visited); if (val != null) { // If it was resolved as a fully qualified enum string like "MyEvents.ORDER_PAID", strip it @@ -671,6 +719,50 @@ public class ConstantResolver { return fields.isEmpty() ? null : fields; } + private List extractArrayElementsFromCreation(ArrayCreation creation, CodebaseContext context, Set visited) { + if (creation.getInitializer() == null) { + return null; + } + List 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 extractArrayElementsFromInitializer(ArrayInitializer initializer, CodebaseContext context, Set visited) { + List 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 visited) { if (expr.getOperator() != InfixExpression.Operator.PLUS) return null; @@ -721,7 +813,18 @@ public class ConstantResolver { if (td != null) { String fqn = context.getFqn(td); String result = resolveFieldInType(td, sn.getIdentifier(), fqn, context, visited); - if (result != null) return result; + if (result != null) { + return result; + } + } + + td = typeFromCurrentCallPath(context); + if (td != null) { + String fqn = context.getFqn(td); + String result = resolveFieldInType(td, sn.getIdentifier(), fqn, context, visited); + if (result != null) { + return result; + } } // Fallback: Static imports @@ -742,6 +845,9 @@ public class ConstantResolver { CompilationUnit contextCu = (CompilationUnit) qn.getRoot(); TypeDeclaration td = context.getTypeDeclaration(qualifier, contextCu); + if (td == null) { + td = resolveTypeInContext(qualifier, qn, context); + } if (td != null) { String fqn = context.getFqn(td); @@ -751,6 +857,67 @@ public class ConstantResolver { return null; } + private String resolveStaticFieldAccess(FieldAccess fa, CodebaseContext context, Set 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 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 visited) { String fieldId = typeFqn + "#" + fieldName; if (visited.contains(fieldId)) { diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/LiteralExpressionSupport.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/LiteralExpressionSupport.java new file mode 100644 index 0000000..246cc3d --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/LiteralExpressionSupport.java @@ -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 visited, + BiFunction, String> resolver) { + Integer index = resolveLiteralInt(access.getIndex(), context, visited, resolver); + if (index == null || index < 0) { + return null; + } + List 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 visited, + BiFunction, 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 entries = extractMapEntries(getCall.getExpression(), context, visited, resolver); + if (entries == null) { + return null; + } + return entries.get(key); + } + + static String resolveLiteralStringTransform( + MethodInvocation transformCall, + Set visited, + BiFunction, 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 visited, + BiFunction, 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 extractMapEntries( + Expression expr, + CodebaseContext context, + Set visited, + BiFunction, String> resolver) { + if (expr instanceof MethodInvocation mi) { + Map 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 extractMapFactoryEntries( + MethodInvocation mi, + CodebaseContext context, + Set visited, + BiFunction, 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 entries = new LinkedHashMap<>(); + for (Object argObj : mi.arguments()) { + if (argObj instanceof Expression entryExpr) { + Map single = extractMapEntryCall(entryExpr, context, visited, resolver); + if (single != null) { + entries.putAll(single); + } + } + } + return entries.isEmpty() ? null : entries; + } + return null; + } + + private static Map extractMapEntryCall( + Expression expr, + CodebaseContext context, + Set visited, + BiFunction, 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 extractAlternatingPairs( + List arguments, + CodebaseContext context, + Set visited, + BiFunction, String> resolver) { + if (arguments.size() < 2) { + return null; + } + Map 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 entries) { + StringBuilder sb = new StringBuilder("MAP:"); + boolean first = true; + for (Map.Entry entry : entries.entrySet()) { + if (!first) { + sb.append('|'); + } + first = false; + sb.append(escape(entry.getKey())).append('=').append(escape(entry.getValue())); + } + return sb.toString(); + } + + static Map parseMapLiteral(String encoded) { + if (encoded == null || !encoded.startsWith("MAP:")) { + return null; + } + Map 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 mapLiteralValues(String encoded) { + Map 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 extractArrayElements( + Expression arrayExpr, + CodebaseContext context, + Set visited, + BiFunction, 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 values) { + return "ARRAY:" + String.join("|", values.stream().map(LiteralExpressionSupport::escape).toList()); + } + + private static List extractExpressionList( + List expressions, + CodebaseContext context, + Set visited, + BiFunction, String> resolver) { + List 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; + } +} diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AbstractCallGraphEngine.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AbstractCallGraphEngine.java index 3558df0..bf0cf06 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AbstractCallGraphEngine.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AbstractCallGraphEngine.java @@ -21,6 +21,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { protected final VariableTracer variableTracer; protected final ConstantExtractor constantExtractor; protected final ConstructorAnalyzer constructorAnalyzer; + protected final ExpressionAccessClassifier expressionAccessClassifier; private final Map parsedNodeCache = new HashMap<>(); private ASTNode parseExpressionString(String expr) { @@ -77,6 +78,9 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { this.constantExtractor.setConstructorAnalyzer(this.constructorAnalyzer); this.constructorAnalyzer.setVariableTracer(this.variableTracer); this.constructorAnalyzer.setConstantExtractor(this.constantExtractor); + this.expressionAccessClassifier = new ExpressionAccessClassifier( + context, variableTracer, constantResolver, this::parseExpressionString); + this.constantExtractor.setExpressionAccessClassifier(this.expressionAccessClassifier); } public List findChains(List entryPoints, List triggers) { @@ -156,7 +160,8 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { String currentParamName = event; String resolvedValue = event; String methodSuffix = ""; - String[] extractedEntry = extractMethodSuffix(currentParamName, methodSuffix); + String scopeMethod = path.isEmpty() ? null : path.get(0); + String[] extractedEntry = extractMethodSuffix(currentParamName, methodSuffix, scopeMethod); currentParamName = extractedEntry[0]; methodSuffix = extractedEntry[1]; boolean isAmbiguous = false; @@ -197,7 +202,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { Map parameterValues = variableTracer.buildParameterValuesMap(caller, target, callGraph, path, i); String tracedVar = variableTracer.traceLocalVariable(target, currentParamName, parameterValues); if (tracedVar != null && !tracedVar.equals(currentParamName)) { - String[] extractedTraced = extractMethodSuffix(tracedVar, methodSuffix); + String[] extractedTraced = extractMethodSuffix(tracedVar, methodSuffix, target); tracedVar = extractedTraced[0]; methodSuffix = extractedTraced[1]; currentParamName = tracedVar; @@ -259,7 +264,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { } // Intentionally not replacing 'super' because super should remain statically bound to the superclass of where the code is defined. } - String[] extractedArg = extractMethodSuffix(arg, methodSuffix); + String[] extractedArg = extractMethodSuffix(arg, methodSuffix, caller); arg = extractedArg[0]; methodSuffix = extractedArg[1]; currentParamName = arg; @@ -290,11 +295,67 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { } String entryMethod = path.get(0); + + if (methodSuffix.startsWith(".get") || methodSuffix.contains(".type") || methodSuffix.contains(".event")) { + List 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 setterEvents = new ArrayList<>(); + List tracedSetters = variableTracer.traceVariableAll(localSetterExpr); + for (Expression tracedSetter : tracedSetters) { + constantExtractor.extractConstantsFromExpression(tracedSetter, setterEvents); + } + if (!setterEvents.isEmpty()) { + log.debug("Early return (scoped setter): setterEvents = {}", setterEvents); + return TriggerPoint.builder() + .event(resolvedValue) + .className(tp.getClassName()) + .methodName(tp.getMethodName()) + .sourceFile(tp.getSourceFile()) + .sourceModule(tp.getSourceModule()) + .stateMachineId(tp.getStateMachineId()) + .sourceState(tp.getSourceState()) + .lineNumber(tp.getLineNumber()) + .polymorphicEvents(setterEvents) + .constraint(tp.getConstraint()) + .stateTypeFqn(tp.getStateTypeFqn()) + .eventTypeFqn(tp.getEventTypeFqn()) + .external(tp.isExternal()) + .build(); + } + } + } + } + int entryParamIndex = typeResolver.getParameterIndex(entryMethod, currentParamName); if (entryParamIndex < 0) { if (methodSuffix.startsWith(".get") || methodSuffix.equals(".type") || methodSuffix.equals(".event") || methodSuffix.equals(".type()") || methodSuffix.equals(".event()")) { String localMethodName = methodSuffix.substring(1).replace("()", ""); - Expression localSetterExpr = variableTracer.traceLocalSetter(entryMethod, currentParamName, localMethodName); + String scopeForVar = resolveScopeMethodForVariable(currentParamName, entryMethod, path); + Expression localSetterExpr = variableTracer.traceLocalSetter(scopeForVar, currentParamName, localMethodName); if (localSetterExpr != null) { List setterEvents = new ArrayList<>(); List tracedSetters = variableTracer.traceVariableAll(localSetterExpr); @@ -304,7 +365,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { if (!setterEvents.isEmpty()) { log.debug("Early return 1: setterEvents = {}", setterEvents); return TriggerPoint.builder() - .event(tp.getEvent()) + .event(resolvedValue) .className(tp.getClassName()) .methodName(tp.getMethodName()) .sourceFile(tp.getSourceFile()) @@ -329,7 +390,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { if (getterEvents != null && !getterEvents.isEmpty()) { log.debug("Early return 2: getterEvents = {}", getterEvents); return TriggerPoint.builder() - .event(tp.getEvent()) + .event(resolvedValue) .className(tp.getClassName()) .methodName(tp.getMethodName()) .sourceFile(tp.getSourceFile()) @@ -348,7 +409,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { String tracedVar = variableTracer.traceLocalVariable(entryMethod, currentParamName); if (tracedVar != null && !tracedVar.equals(currentParamName)) { - String[] extractedFinalTraced = extractMethodSuffix(tracedVar, methodSuffix); + String[] extractedFinalTraced = extractMethodSuffix(tracedVar, methodSuffix, entryMethod); tracedVar = extractedFinalTraced[0]; methodSuffix = extractedFinalTraced[1]; currentParamName = tracedVar; @@ -366,7 +427,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { if (!polymorphicEvents.contains(parsed)) polymorphicEvents.add(parsed); } return TriggerPoint.builder() - .event(tp.getEvent()) + .event(resolvedValue) .className(tp.getClassName()) .methodName(tp.getMethodName()) .sourceFile(tp.getSourceFile()) @@ -394,17 +455,62 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { if (exprNode instanceof MethodInvocation mi) { methodName = mi.getName().getIdentifier(); + if ("get".equals(methodName) || "getOrDefault".equals(methodName)) { + boolean keyedLookup = expressionAccessClassifier.isKeyedLookup(mi, entryMethod); + boolean functionalGet = "get".equals(methodName) && mi.arguments().isEmpty() && !keyedLookup; + if (keyedLookup || functionalGet) { + constantExtractor.extractConstantsFromExpression((Expression) exprNode, polymorphicEvents); + if (!polymorphicEvents.isEmpty()) { + return TriggerPoint.builder() + .event(resolvedValue) + .className(tp.getClassName()) + .methodName(tp.getMethodName()) + .sourceFile(tp.getSourceFile()) + .sourceModule(tp.getSourceModule()) + .stateMachineId(tp.getStateMachineId()) + .sourceState(tp.getSourceState()) + .lineNumber(tp.getLineNumber()) + .polymorphicEvents(polymorphicEvents) + .constraint(tp.getConstraint()) + .stateTypeFqn(tp.getStateTypeFqn()) + .eventTypeFqn(tp.getEventTypeFqn()) + .external(tp.isExternal()) + .ambiguous(isAmbiguous) + .build(); + } + } + } boolean handledStaticEnum = false; if (methodName.equals("valueOf") && mi.arguments().size() == 1) { Object arg = mi.arguments().get(0); + String enumTypeName = resolveValueOfEnumTypeName(mi); if (arg instanceof StringLiteral sl) { - polymorphicEvents.add(sl.getLiteralValue()); + if (enumTypeName != null) { + polymorphicEvents.add(enumTypeName + "." + sl.getLiteralValue()); + } else { + polymorphicEvents.add(sl.getLiteralValue()); + } methodName = null; handledStaticEnum = true; } 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; handledStaticEnum = true; + } else if (arg instanceof SimpleName sn) { + String resolvedConstant = resolveEnumConstantFromArgument(sn.getIdentifier(), path, callGraph); + if (resolvedConstant != null) { + if (enumTypeName != null) { + polymorphicEvents.add(enumTypeName + "." + resolvedConstant); + } else { + polymorphicEvents.add(resolvedConstant); + } + methodName = null; + handledStaticEnum = true; + } } } else if (methodName.equals("name") && mi.arguments().isEmpty()) { if (mi.getExpression() instanceof QualifiedName qn) { @@ -444,7 +550,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { } } return TriggerPoint.builder() - .event(tp.getEvent()) + .event(resolvedValue) .className(tp.getClassName()) .methodName(tp.getMethodName()) .sourceFile(tp.getSourceFile()) @@ -579,7 +685,8 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { if (methodName != null) { if (varName != null && !varName.isEmpty() && Character.isLowerCase(varName.charAt(0)) && !methodName.equals("VariableReference")) { - Expression localSetterExpr = variableTracer.traceLocalSetter(entryMethod, varName, methodName); + String scopeForVar = resolveScopeMethodForVariable(varName, entryMethod, path); + Expression localSetterExpr = variableTracer.traceLocalSetter(scopeForVar, varName, methodName); if (localSetterExpr != null) { List tracedSetters = variableTracer.traceVariableAll(localSetterExpr); for (Expression tracedSetter : tracedSetters) { @@ -724,10 +831,14 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { if (declaredType != null) { List enumValues = context.getEnumValues(declaredType); if (enumValues != null && !enumValues.isEmpty()) { - for (String ev : enumValues) { - polymorphicEvents.add(constantExtractor.parseEnumSetElement(ev)); + if (!hasConcreteEnumConstants(polymorphicEvents, declaredType, context) + && !tp.isExternal() + && !isRuntimeEnumParameter(exprNode instanceof Expression expression ? expression : null)) { + for (String ev : enumValues) { + polymorphicEvents.add(constantExtractor.parseEnumSetElement(ev)); + } + isAmbiguous = true; } - isAmbiguous = true; } else { List typesToInspect = new ArrayList<>(); if ("inline-instantiation".equals(sourceMethod)) { @@ -849,6 +960,11 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { polymorphicEvents = unpacked; 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(" path) { + if (varName != null && path != null) { + for (String methodFqn : path) { + if (variableTracer.getVariableDeclaredType(methodFqn, varName) != null) { + return methodFqn; + } + } + } + return entryMethod; + } + protected List evaluateGetterOnReceiver(String resolvedValue, String methodSuffix, String entryMethod, List path, Map> callGraph) { ASTNode exprNode = parseExpressionString(resolvedValue); while (exprNode instanceof ParenthesizedExpression pe) { exprNode = pe.getExpression(); } + String scopeMethod = entryMethod; + Expression rootExpr = exprNode instanceof Expression e ? e : null; + if (rootExpr != null) { + while (rootExpr instanceof MethodInvocation mi) { + rootExpr = mi.getExpression(); + } + if (rootExpr instanceof SimpleName sn) { + scopeMethod = resolveScopeMethodForVariable(sn.getIdentifier(), entryMethod, path); + } + } + if (exprNode instanceof SuperMethodInvocation smi) { String getterName = smi.getName().getIdentifier(); int lastDot = entryMethod.lastIndexOf('.'); @@ -1055,25 +1267,31 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { if (receiver instanceof SimpleName sn) { receiverName = sn.getIdentifier(); - receiverType = variableTracer.getVariableDeclaredType(entryMethod, receiverName); + receiverType = variableTracer.getVariableDeclaredType(scopeMethod, receiverName); } else if (receiver instanceof ClassInstanceCreation cic) { directCic = cic; receiverType = cic.getType().toString(); } else if (receiver instanceof MethodInvocation receiverMi) { - Expression currentMi = receiverMi; - while (currentMi instanceof MethodInvocation chainMi) { - String mName = chainMi.getName().getIdentifier(); - if (mName.equalsIgnoreCase("set" + propName) || mName.equalsIgnoreCase(propName)) { - if (!chainMi.arguments().isEmpty()) { - Expression arg = (Expression) chainMi.arguments().get(0); - List builderConstants = new ArrayList<>(); - constantExtractor.extractConstantsFromExpression(arg, builderConstants); - if (!builderConstants.isEmpty()) { - return builderConstants; + GetterChainReceiver chainedReceiver = resolveGetterChainToReceiver(receiverMi, scopeMethod); + if (chainedReceiver != null) { + receiverType = chainedReceiver.typeFqn(); + directCic = chainedReceiver.cic(); + } else { + Expression currentMi = receiverMi; + while (currentMi instanceof MethodInvocation chainMi) { + String mName = chainMi.getName().getIdentifier(); + if (mName.equalsIgnoreCase("set" + propName) || mName.equalsIgnoreCase(propName)) { + if (!chainMi.arguments().isEmpty()) { + Expression arg = (Expression) chainMi.arguments().get(0); + List builderConstants = new ArrayList<>(); + constantExtractor.extractConstantsFromExpression(arg, builderConstants); + if (!builderConstants.isEmpty()) { + return builderConstants; + } } } + currentMi = chainMi.getExpression(); } - currentMi = chainMi.getExpression(); } } else if (receiver instanceof SuperMethodInvocation) { int lastDot = entryMethod.lastIndexOf('.'); @@ -1123,7 +1341,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { if (directCic != null) { cic = directCic; } else { - Expression initExpr = findVariableInitializer(entryMethod, varName); + Expression initExpr = findVariableInitializer(scopeMethod, varName); if (initExpr instanceof MethodInvocation initMi) { initExpr = unwrapMethodInvocation(initMi, 0, entryMethod); } @@ -1148,18 +1366,6 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { } if (getter != null && getter.getBody() != null) { fieldValues.putAll(constructorAnalyzer.buildFieldValuesFromCIC(td, cic, context, this::evaluateMethodCallInConstructor)); - int lastDotEntry = entryMethod.lastIndexOf('.'); - if (lastDotEntry > 0) { - String className = entryMethod.substring(0, lastDotEntry); - String methodName = entryMethod.substring(lastDot + 1); - TypeDeclaration entryTd = context.getTypeDeclaration(className); - if (entryTd != null) { - MethodDeclaration entryMd = context.findMethodDeclaration(entryTd, methodName, false); - if (entryMd != null && entryMd.getBody() != null) { - // Skip trackSetterCallsBetween - } - } - } String result = constantResolver.evaluateMethodBodyWithLocals(getter, fieldValues, context, new HashSet<>()); if (result != null) { String returnType = getter.getReturnType2() != null ? getter.getReturnType2().toString() : null; @@ -1208,7 +1414,22 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { return initExpr[0]; } + /** + * Splits a traced argument into a receiver base and a trailing accessor suffix for getter-chain + * resolution, e.g. {@code wrapper.getEvent()} → {@code ["wrapper", ".getEvent()"]}. + *

+ * Map/collection lookups ({@code ROUTES.get(key)}) stay intact and are resolved by + * {@link ConstantExtractor}; see {@link ExpressionAccessClassifier} for the type-based distinction + * from {@code Supplier.get()} and JavaBean accessors. + */ protected String[] extractMethodSuffix(String paramName, String currentSuffix) { + return extractMethodSuffix(paramName, currentSuffix, null); + } + + protected String[] extractMethodSuffix(String paramName, String currentSuffix, String scopeMethod) { + if (expressionAccessClassifier.isKeyedLookup(paramName, scopeMethod)) { + return new String[] { paramName, currentSuffix }; + } if (paramName != null && !paramName.contains(" ") && !paramName.startsWith("new ")) { int bracketIndex = paramName.indexOf('['); int dotIndex = -1; @@ -1591,12 +1812,22 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { return allResolved; } + /** + * Peels enum factory wrappers while tracing call-path arguments, e.g. + * {@code OrderEvent.valueOf(action)} → {@code action} so the next hop can resolve the parameter. + *

+ * Map/collection lookups ({@code ROUTES.get(key)}) are intentionally not peeled — they are + * routing tables, not enum converters. See {@link ExpressionAccessClassifier}. + */ private String unwrapConverterMethods(String arg) { if (arg == null) return null; String current = arg; while (current.contains("(") && !current.startsWith("new ")) { org.eclipse.jdt.core.dom.ASTNode argNode = parseExpressionString(current); if (argNode instanceof org.eclipse.jdt.core.dom.MethodInvocation miArg && !miArg.arguments().isEmpty()) { + if (expressionAccessClassifier.isKeyedLookup(miArg, null)) { + break; + } boolean shouldUnpack = false; org.eclipse.jdt.core.dom.Expression recv = miArg.getExpression(); if (recv == null || recv instanceof org.eclipse.jdt.core.dom.ThisExpression) { @@ -1619,4 +1850,118 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { } return current; } + + private String resolveValueOfEnumTypeName(MethodInvocation mi) { + if (mi.getExpression() instanceof SimpleName sn) { + return sn.getIdentifier(); + } + if (mi.getExpression() instanceof QualifiedName qn) { + String full = qn.getFullyQualifiedName(); + return full.contains(".") ? full.substring(full.lastIndexOf('.') + 1) : full; + } + return null; + } + + private String resolveEnumConstantFromArgument(String argName, List path, Map> 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 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 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 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 enumValues = context.getEnumValues(declaredType); + + for (String pe : polymorphicEvents) { + if (pe == null || pe.startsWith(" calledMethodResolver; private VariableTracer variableTracer; private ConstructorAnalyzer constructorAnalyzer; + private ExpressionAccessClassifier expressionAccessClassifier; public ConstantExtractor(CodebaseContext context, ConstantResolver constantResolver, Function calledMethodResolver) { this.context = context; @@ -29,25 +30,20 @@ public class ConstantExtractor { this.constructorAnalyzer = constructorAnalyzer; } + public void setExpressionAccessClassifier(ExpressionAccessClassifier expressionAccessClassifier) { + this.expressionAccessClassifier = expressionAccessClassifier; + } + public void extractConstantsFromExpression(Expression expr, List constants) { if (constantResolver != null) { String resolved = constantResolver.resolve(expr, context); if (resolved != null) { - if (resolved.startsWith("ENUM_SET:")) { - for (String eVal : resolved.substring(9).split(",")) { - String parsed = parseEnumSetElement(eVal); - if (!constants.contains(parsed)) constants.add(parsed); - } - } else { - constants.add(resolved); - } + addResolvedConstant(resolved, constants); return; } } - if (expr instanceof QualifiedName qn) { - constants.add(qn.toString()); - } else if (expr instanceof StringLiteral sl) { + if (expr instanceof StringLiteral sl) { constants.add(sl.getLiteralValue()); } else if (expr instanceof ConditionalExpression ce) { extractConstantsFromExpression(ce.getThenExpression(), constants); @@ -90,18 +86,15 @@ public class ConstantExtractor { String methodName = mi.getName().getIdentifier(); log.trace("Processing mi: {}", mi); - if (methodName.equals("get") || methodName.equals("getOrDefault")) { - if (mi.getExpression() != null) { - if (variableTracer != null) { - List traced = variableTracer.traceVariableAll(mi.getExpression()); - for (Expression t : traced) { - extractConstantsFromExpression(t, constants); - } - } else { - extractConstantsFromExpression(mi.getExpression(), constants); - } + if (("get".equals(methodName) || "getOrDefault".equals(methodName)) && mi.getExpression() != null) { + if (isKeyedLookupMethod(mi)) { + extractMapReceiverConstants(mi.getExpression(), constants); + return; + } + if ("get".equals(methodName) && mi.arguments().isEmpty()) { + extractMapReceiverConstants(mi.getExpression(), constants); + return; } - return; } if (methodName.equals("of") || methodName.equals("ofEntries") || methodName.equals("asList") || methodName.equals("entry") || 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; } + 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 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 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 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 mapValues = ConstantResolver.decodeMapLiteralValues(resolved); + if (mapValues != null) { + for (String mapValue : mapValues) { + if (!constants.contains(mapValue)) { + constants.add(mapValue); + } + } + } + } else if (resolved.startsWith("ARRAY:")) { + for (String arrayValue : resolved.substring(6).split("\\|", -1)) { + if (!constants.contains(arrayValue)) { + constants.add(arrayValue); + } + } + } else if (!constants.contains(resolved)) { + constants.add(resolved); + } + } + private Block findEnclosingBlock(ASTNode node) { ASTNode parent = node.getParent(); while (parent != null && !(parent instanceof Block)) { diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/ExpressionAccessClassifier.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/ExpressionAccessClassifier.java new file mode 100644 index 0000000..74d7ee0 --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/ExpressionAccessClassifier.java @@ -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 expressionParser; + + public ExpressionAccessClassifier( + CodebaseContext context, + VariableTracer variableTracer, + ConstantResolver constantResolver, + Function 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 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; + } +} diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/JdtDataFlowModel.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/JdtDataFlowModel.java index 8cfdd2d..b8d7834 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/JdtDataFlowModel.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/JdtDataFlowModel.java @@ -18,6 +18,10 @@ public class JdtDataFlowModel implements DataFlowModel { CURRENT_PATH.set(path); } + public static List getCurrentPath() { + return CURRENT_PATH.get(); + } + public static void clearCurrentPath() { CURRENT_PATH.remove(); } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/command/ExporterCommand.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/command/ExporterCommand.java index a784f07..cdba76c 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/command/ExporterCommand.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/command/ExporterCommand.java @@ -57,8 +57,8 @@ public class ExporterCommand implements Callable { @Option(names = {"--debug"}, description = "Enable diagnostic mode to show unresolved call chains and detailed analysis logging.", defaultValue = "false") private boolean debug; - @Option(names = {"--resolve-classpath"}, description = "Automatically run Maven/Gradle to resolve full classpath dependencies for perfect AST bindings.", defaultValue = "false") - private boolean resolveClasspath; + @Option(names = {"--resolve-classpath"}, description = "Automatically run Maven/Gradle to resolve full classpath dependencies for perfect AST bindings.", defaultValue = "true", fallbackValue = "true") + private boolean resolveClasspath = true; @Override public Integer call() throws Exception { diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/TransitionLinkerEnricherTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/TransitionLinkerEnricherTest.java index 4e65a42..a883dd7 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/TransitionLinkerEnricherTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/TransitionLinkerEnricherTest.java @@ -17,6 +17,57 @@ class TransitionLinkerEnricherTest { private final TransitionLinkerEnricher enricher = new TransitionLinkerEnricher(); + @Test + void shouldLinkWhenPolymorphicEventsContainConcreteEnumConstant() { + Transition payT = new Transition(); + payT.setSourceStates(List.of(State.of("NEW", "OrderState.NEW"))); + payT.setTargetStates(List.of(State.of("PAID", "OrderState.PAID"))); + payT.setEvent(Event.of("OrderEvent.PAY", "com.example.OrderEvent.PAY")); + + CallChain chain = CallChain.builder() + .triggerPoint(TriggerPoint.builder() + .event("e") + .polymorphicEvents(List.of("OrderEvent.PAY")) + .build()) + .build(); + + AnalysisResult result = AnalysisResult.builder() + .name("OrderStateMachineConfig") + .transitions(List.of(payT)) + .metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build()) + .build(); + + enricher.enrich(result, null, null); + + assertThat(result.getMetadata().getCallChains().get(0).getMatchedTransitions()).hasSize(1); + } + + @Test + void shouldRespectDispatcherBranchConstraintForMachineDomain() { + Transition payT = new Transition(); + payT.setSourceStates(List.of(State.of("NEW", "OrderState.NEW"))); + payT.setTargetStates(List.of(State.of("PAID", "OrderState.PAID"))); + payT.setEvent(Event.of("OrderEvent.PAY", "com.example.OrderEvent.PAY")); + + CallChain chain = CallChain.builder() + .triggerPoint(TriggerPoint.builder() + .event("OrderEvent.PAY") + .polymorphicEvents(List.of("OrderEvent.PAY")) + .constraint("\"ORDER\".equalsIgnoreCase(type)") + .build()) + .build(); + + AnalysisResult result = AnalysisResult.builder() + .name("com.example.OrderStateMachineConfig") + .transitions(List.of(payT)) + .metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build()) + .build(); + + enricher.enrich(result, null, null); + + assertThat(result.getMetadata().getCallChains().get(0).getMatchedTransitions()).hasSize(1); + } + @Test void shouldLinkTransitionByEventOnly() { Transition t1 = new Transition(); @@ -543,4 +594,30 @@ class TransitionLinkerEnricherTest { enricher.enrich(resultUser, null, null); assertThat(resultUser.getMetadata().getCallChains().get(0).getMatchedTransitions()).isNullOrEmpty(); } + + @Test + void shouldRejectChainWhenDispatcherDomainConstraintDoesNotMatchMachine() { + Transition payT = new Transition(); + payT.setSourceStates(List.of(State.of("NEW", "OrderState.NEW"))); + payT.setTargetStates(List.of(State.of("PAID", "OrderState.PAID"))); + payT.setEvent(Event.of("OrderEvent.PAY", "com.example.OrderEvent.PAY")); + + CallChain chain = CallChain.builder() + .triggerPoint(TriggerPoint.builder() + .event("OrderEvent.PAY") + .polymorphicEvents(List.of("OrderEvent.PAY")) + .constraint("\"PAYMENT\".equalsIgnoreCase(type)") + .build()) + .build(); + + AnalysisResult result = AnalysisResult.builder() + .name("com.example.OrderStateMachineConfig") + .transitions(List.of(payT)) + .metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build()) + .build(); + + enricher.enrich(result, null, null); + + assertThat(result.getMetadata().getCallChains().get(0).getMatchedTransitions()).isNullOrEmpty(); + } } \ No newline at end of file diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/matching/StrictFqnMatchingEngineTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/matching/StrictFqnMatchingEngineTest.java index df41b39..7396ea4 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/matching/StrictFqnMatchingEngineTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/matching/StrictFqnMatchingEngineTest.java @@ -142,14 +142,37 @@ class StrictFqnMatchingEngineTest { } @Test - void shouldMatchValueOfWithCorrectEnum() { + void shouldMatchValueOfWithCorrectEnumWhenPolymorphicEventIsResolved() { + Event smEvent = Event.of("PAY", "com.example.OrderEvents.PAY"); + TriggerPoint triggerPoint = TriggerPoint.builder() + .event("OrderEvents.valueOf(str)") + .eventTypeFqn("com.example.OrderEvents") + .polymorphicEvents(List.of("OrderEvents.PAY")) + .build(); + + assertThat(engine.matches(smEvent, triggerPoint)).isTrue(); + } + + @Test + void shouldNotMatchUnresolvedValueOfWithoutPolymorphicEvents() { Event smEvent = Event.of("PAY", "com.example.OrderEvents.PAY"); TriggerPoint triggerPoint = TriggerPoint.builder() .event("OrderEvents.valueOf(str)") .eventTypeFqn("com.example.OrderEvents") .build(); - assertThat(engine.matches(smEvent, triggerPoint)).isTrue(); + assertThat(engine.matches(smEvent, triggerPoint)).isFalse(); + } + + @Test + void shouldNotMatchExternalTriggerWithoutPolymorphicEvents() { + Event smEvent = Event.of("PAY", "com.example.OrderEvents.PAY"); + TriggerPoint triggerPoint = TriggerPoint.builder() + .event("event") + .external(true) + .build(); + + assertThat(engine.matches(smEvent, triggerPoint)).isFalse(); } @Test diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/BooleanConstraintEvaluatorTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/BooleanConstraintEvaluatorTest.java new file mode 100644 index 0000000..5ea976d --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/BooleanConstraintEvaluatorTest.java @@ -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(); + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/ConstantResolverTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/ConstantResolverTest.java index cab22b1..9df1b8e 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/ConstantResolverTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/ConstantResolverTest.java @@ -1,5 +1,6 @@ package click.kamil.springstatemachineexporter.analysis.resolver; +import click.kamil.springstatemachineexporter.analysis.service.ConstantExtractor; import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; import org.eclipse.jdt.core.dom.*; import org.junit.jupiter.api.Test; @@ -8,6 +9,8 @@ import org.junit.jupiter.api.io.TempDir; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; import static org.assertj.core.api.Assertions.assertThat; @@ -524,4 +527,166 @@ public class ConstantResolverTest { String result2 = resolver.resolve(mi2, context); assertThat(result2).isEqualTo("com.example.OrderEvent.SHIPPED"); } + + @Test + void shouldResolveMapOfGetWithLiteralKey(@TempDir Path tempDir) throws IOException { + Path dir = tempDir.resolve("com/example"); + Files.createDirectories(dir); + Files.writeString(dir.resolve("Routes.java"), + "package com.example;\n" + + "public class Routes {\n" + + " public static final String PAY = \"OrderEvents.PAY\";\n" + + " public static final String SHIP = \"OrderEvents.SHIP\";\n" + + " public String lookup() {\n" + + " return java.util.Map.of(\"order.pay\", PAY, \"order.ship\", SHIP).get(\"order.pay\");\n" + + " }\n" + + "}"); + + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + + TypeDeclaration td = context.getTypeDeclaration("com.example.Routes"); + MethodDeclaration lookup = td.getMethods()[0]; + ReturnStatement rs = (ReturnStatement) lookup.getBody().statements().get(0); + + ConstantResolver resolver = new ConstantResolver(); + String result = resolver.resolve(rs.getExpression(), context); + + assertThat(result).isEqualTo("OrderEvents.PAY"); + } + + @Test + void shouldResolveStaticMapFieldGetWithLiteralKey(@TempDir Path tempDir) throws IOException { + Path dir = tempDir.resolve("com/example"); + Files.createDirectories(dir); + Files.writeString(dir.resolve("Dispatcher.java"), + "package com.example;\n" + + "import java.util.Map;\n" + + "public class Dispatcher {\n" + + " private static final Map 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 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 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"); + } } diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/AnalysisCacheCorrectnessTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/AnalysisCacheCorrectnessTest.java new file mode 100644 index 0000000..b44e08e --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/AnalysisCacheCorrectnessTest.java @@ -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. + * + *

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 configNames = List.of( + "F1StateMachineConfiguration", + "F2StateMachineConfiguration", + "G1StateMachineConfiguration", + "G2StateMachineConfiguration"); + + Map firstPass = enrichAllConfigs(context, intelligence, enrichmentService, configNames); + Map secondPass = enrichAllConfigs(context, intelligence, enrichmentService, configNames); + + assertThat(secondPass).isEqualTo(firstPass); + assertThat(firstPass).hasSize(4); + + List 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. + * + *

+     * RootAbstractSmConfig
+     * ├── BranchAAbstractSmConfig → BranchA1..A4SmConfig
+     * ├── BranchBAbstractSmConfig → BranchB1..B4SmConfig
+     * └── BranchCAbstractSmConfig → BranchC1..C4SmConfig
+     * 
+ */ + @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 configNames = TwelveConfigHierarchyTestSupport.concreteConfigNames(); + + Map firstPass = enrichAllConfigs(context, intelligence, enrichmentService, configNames); + Object cachedGraphAfterFirstPass = context.getCache().get("jdtCallGraph"); + + Map 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 enrichAllConfigs( + CodebaseContext context, + JdtIntelligenceProvider intelligence, + EnrichmentService enrichmentService, + List configNames) { + Map 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 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 eventNames) { + static Snapshot from(AnalysisResult result) { + List 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; + } + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/CallGraphExpressionShapeTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/CallGraphExpressionShapeTest.java new file mode 100644 index 0000000..506e018 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/CallGraphExpressionShapeTest.java @@ -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 ROUTES = Map.of("k", "v"); + private EventWrapper eventWrapper; + private Supplier eventSupplier; + + public void handle(String commandKey) { + ROUTES.get(commandKey); + Routes.ROUTES.get(commandKey); + eventWrapper.getEvent(); + eventSupplier.get(); + } + } + + class Routes { + static final Map 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()"); + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/CentralDispatcherResolutionTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/CentralDispatcherResolutionTest.java new file mode 100644 index 0000000..a88e8ed --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/CentralDispatcherResolutionTest.java @@ -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. + * + *

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. + * + *

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. + * + *

+     * payOrder()  → dispatch("ORDER", "PAY")  → OrderEvent.PAY
+     * shipOrder() → dispatch("ORDER", "SHIP") → OrderEvent.SHIP
+     * 
+ */ + @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. + * + *
+     * payV2()  → dispatch("ORDER", "PAY", "v2")  → OrderEvent.valueOf(action)
+     * shipV2() → dispatch("ORDER", "SHIP", "v2") → OrderEvent.valueOf(action)
+     * 
+ * + *

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}. + * + *

+     * pay()  → gateway.payWithRichEvent(new PayRichEvent()) → central.orderPay(e) → e.getType() → OrderEvent.PAY
+     * ship() → gateway.shipWithRichEvent(new ShipRichEvent()) → central.orderShip(e) → e.getType() → OrderEvent.SHIP
+     * 
+ */ + @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"); + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/CentralDispatcherTestSupport.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/CentralDispatcherTestSupport.java new file mode 100644 index 0000000..3162899 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/CentralDispatcherTestSupport.java @@ -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 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 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); + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/DispatcherEndpointTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/DispatcherEndpointTest.java new file mode 100644 index 0000000..d582c57 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/DispatcherEndpointTest.java @@ -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(); + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/EnterpriseBugsTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/EnterpriseBugsTest.java index 7892d12..24012c0 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/EnterpriseBugsTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/EnterpriseBugsTest.java @@ -269,6 +269,7 @@ class EnterpriseBugsTest { TriggerPoint triggerPoint = TriggerPoint.builder() .event("OrderEvents.valueOf(someVar)") + .polymorphicEvents(List.of("OrderEvents.PAY")) .build(); assertThat(engine.matches(smEvent, triggerPoint)).isTrue(); diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/ExpressionAccessClassifierTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/ExpressionAccessClassifierTest.java new file mode 100644 index 0000000..3863c89 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/ExpressionAccessClassifierTest.java @@ -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 ROUTES = Map.of("k", "v"); + private EventWrapper wrapper; + private Supplier eventSupplier; + + public void handle(String commandKey) { + ROUTES.get(commandKey); + Routes.ROUTES.get(commandKey); + wrapper.getEvent(); + eventSupplier.get(); + } + } + + class Routes { + static final Map 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 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")).isTrue(); + assertThat(ExpressionAccessClassifier.isMapLikeType("java.util.HashMap")).isTrue(); + assertThat(ExpressionAccessClassifier.isIndexedCollectionType("List")).isTrue(); + assertThat(ExpressionAccessClassifier.isMapLikeType("Supplier")).isFalse(); + } + + private static ASTNode parseExpression(String expr) { + ASTParser parser = ASTParser.newParser(AST.JLS17); + Map 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); + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/HeuristicCallGraphEngineCoreTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/HeuristicCallGraphEngineCoreTest.java index 49ccdab..e32e768 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/HeuristicCallGraphEngineCoreTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/HeuristicCallGraphEngineCoreTest.java @@ -420,8 +420,8 @@ class HeuristicCallGraphEngineCoreTest { assertThat(chains).hasSize(1); CallChain chain = chains.get(0); - // It might not fully resolve chained method calls, but it must not crash! - assertThat(chain.getTriggerPoint().getEvent()).isEqualTo("wrapper.getEvent().getType()"); + assertThat(chain.getTriggerPoint().getPolymorphicEvents()) + .containsExactlyInAnyOrder("OrderEvents.PAY"); } @Test diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/LayeredDispatcherSampleTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/LayeredDispatcherSampleTest.java new file mode 100644 index 0000000..117add1 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/LayeredDispatcherSampleTest.java @@ -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); + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/LiteralDataFlowResolutionTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/LiteralDataFlowResolutionTest.java new file mode 100644 index 0000000..b565afb --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/LiteralDataFlowResolutionTest.java @@ -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 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 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 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 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 constants = new ArrayList<>(); + extractor.extractConstantsFromExpression(expr, constants); + return constants; + } + + @Test + void bareMapOfShouldExpandAllValuesNotRawMapEncoding(@TempDir Path tempDir) throws IOException { + List constants = extract(""" + package com.example; + import java.util.Map; + public class Routes { + public Map 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 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 constants = extract(""" + package com.example; + import java.util.Map; + public class Routes { + private static final Map 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 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 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 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 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"); + } + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/TwelveConfigHierarchyTestSupport.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/TwelveConfigHierarchyTestSupport.java new file mode 100644 index 0000000..92bc125 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/TwelveConfigHierarchyTestSupport.java @@ -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. + * + *
+ * RootAbstractSmConfig
+ * ├── BranchAAbstractSmConfig ── BranchA1..4SmConfig
+ * ├── BranchBAbstractSmConfig ── BranchB1..4SmConfig
+ * └── BranchCAbstractSmConfig ── BranchC1..4SmConfig
+ * 
+ * + *

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 BRANCHES = List.of("A", "B", "C"); + + private TwelveConfigHierarchyTestSupport() { + } + + static List concreteConfigNames() { + List 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 expectedEventsForConcrete(String branch, int index) { + return List.of("ROOT_E", branchEvent(branch), concreteEvent(branch, index)); + } +} \ No newline at end of file diff --git a/state_machines/layered_dispatcher_sample/build.gradle b/state_machines/layered_dispatcher_sample/build.gradle new file mode 100644 index 0000000..d82b462 --- /dev/null +++ b/state_machines/layered_dispatcher_sample/build.gradle @@ -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 } diff --git a/state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/LayeredDispatcherApplication.java b/state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/LayeredDispatcherApplication.java new file mode 100644 index 0000000..e9aa295 --- /dev/null +++ b/state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/LayeredDispatcherApplication.java @@ -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); + } +} diff --git a/state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/command/DomainCommand.java b/state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/command/DomainCommand.java new file mode 100644 index 0000000..09c55f0 --- /dev/null +++ b/state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/command/DomainCommand.java @@ -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 +} diff --git a/state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/dispatch/CentralEventDispatcher.java b/state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/dispatch/CentralEventDispatcher.java new file mode 100644 index 0000000..8d3a461 --- /dev/null +++ b/state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/dispatch/CentralEventDispatcher.java @@ -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 orderStateMachineFactory; + private final StateMachineFactory documentStateMachineFactory; + + @Autowired + public CentralEventDispatcher( + StateMachineFactory orderStateMachineFactory, + StateMachineFactory 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 machine = orderStateMachineFactory.getStateMachine(); + machine.start(); + machine.sendEvent(event); + } + + private void sendDocumentEvent(DocumentTransitionEvent event) { + StateMachine machine = documentStateMachineFactory.getStateMachine(); + machine.start(); + machine.sendEvent(event); + } +} diff --git a/state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/dispatch/StringCommandMapper.java b/state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/dispatch/StringCommandMapper.java new file mode 100644 index 0000000..6232418 --- /dev/null +++ b/state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/dispatch/StringCommandMapper.java @@ -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); + }; + } +} diff --git a/state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/document/DocumentState.java b/state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/document/DocumentState.java new file mode 100644 index 0000000..34dfdce --- /dev/null +++ b/state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/document/DocumentState.java @@ -0,0 +1,8 @@ +package click.kamil.examples.statemachine.layered.document; + +public enum DocumentState { + DRAFT, + SUBMITTED, + APPROVED, + REJECTED +} diff --git a/state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/document/DocumentTransitionEvent.java b/state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/document/DocumentTransitionEvent.java new file mode 100644 index 0000000..1cc0e66 --- /dev/null +++ b/state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/document/DocumentTransitionEvent.java @@ -0,0 +1,7 @@ +package click.kamil.examples.statemachine.layered.document; + +public enum DocumentTransitionEvent { + SUBMIT, + APPROVE, + REJECT +} diff --git a/state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/document/config/AbstractDocumentStateMachineConfiguration.java b/state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/document/config/AbstractDocumentStateMachineConfiguration.java new file mode 100644 index 0000000..9098170 --- /dev/null +++ b/state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/document/config/AbstractDocumentStateMachineConfiguration.java @@ -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 { + + @Override + public void configure(StateMachineStateConfigurer states) + throws Exception { + states + .withStates() + .initial(DocumentState.DRAFT) + .state(DocumentState.SUBMITTED) + .state(DocumentState.APPROVED); + configureAdditionalStates(states); + } + + @Override + public void configure(StateMachineTransitionConfigurer 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 states) + throws Exception { + } + + protected void configureAdditionalTransitions( + StateMachineTransitionConfigurer transitions) throws Exception { + } +} diff --git a/state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/document/config/StandardDocumentStateMachineConfiguration.java b/state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/document/config/StandardDocumentStateMachineConfiguration.java new file mode 100644 index 0000000..94cd73c --- /dev/null +++ b/state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/document/config/StandardDocumentStateMachineConfiguration.java @@ -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 states) + throws Exception { + states.state(DocumentState.REJECTED); + } + + @Override + protected void configureAdditionalTransitions( + StateMachineTransitionConfigurer transitions) throws Exception { + transitions + .withExternal() + .source(DocumentState.SUBMITTED) + .target(DocumentState.REJECTED) + .event(DocumentTransitionEvent.REJECT); + } +} diff --git a/state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/order/OrderState.java b/state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/order/OrderState.java new file mode 100644 index 0000000..30a3838 --- /dev/null +++ b/state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/order/OrderState.java @@ -0,0 +1,8 @@ +package click.kamil.examples.statemachine.layered.order; + +public enum OrderState { + NEW, + PAID, + SHIPPED, + CANCELLED +} diff --git a/state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/order/OrderTransitionEvent.java b/state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/order/OrderTransitionEvent.java new file mode 100644 index 0000000..29426c3 --- /dev/null +++ b/state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/order/OrderTransitionEvent.java @@ -0,0 +1,7 @@ +package click.kamil.examples.statemachine.layered.order; + +public enum OrderTransitionEvent { + PAY, + SHIP, + CANCEL +} diff --git a/state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/order/PayRichOrderEvent.java b/state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/order/PayRichOrderEvent.java new file mode 100644 index 0000000..8f21021 --- /dev/null +++ b/state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/order/PayRichOrderEvent.java @@ -0,0 +1,8 @@ +package click.kamil.examples.statemachine.layered.order; + +public class PayRichOrderEvent implements RichOrderEvent { + @Override + public OrderTransitionEvent getType() { + return OrderTransitionEvent.PAY; + } +} diff --git a/state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/order/RichOrderEvent.java b/state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/order/RichOrderEvent.java new file mode 100644 index 0000000..ef954c8 --- /dev/null +++ b/state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/order/RichOrderEvent.java @@ -0,0 +1,7 @@ +package click.kamil.examples.statemachine.layered.order; + +import click.kamil.examples.statemachine.layered.order.OrderTransitionEvent; + +public interface RichOrderEvent { + OrderTransitionEvent getType(); +} diff --git a/state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/order/ShipRichOrderEvent.java b/state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/order/ShipRichOrderEvent.java new file mode 100644 index 0000000..71d97bb --- /dev/null +++ b/state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/order/ShipRichOrderEvent.java @@ -0,0 +1,8 @@ +package click.kamil.examples.statemachine.layered.order; + +public class ShipRichOrderEvent implements RichOrderEvent { + @Override + public OrderTransitionEvent getType() { + return OrderTransitionEvent.SHIP; + } +} diff --git a/state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/order/config/AbstractOrderStateMachineConfiguration.java b/state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/order/config/AbstractOrderStateMachineConfiguration.java new file mode 100644 index 0000000..b381d3f --- /dev/null +++ b/state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/order/config/AbstractOrderStateMachineConfiguration.java @@ -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 { + + @Override + public void configure(StateMachineStateConfigurer states) throws Exception { + states + .withStates() + .initial(OrderState.NEW) + .state(OrderState.PAID) + .state(OrderState.SHIPPED); + configureAdditionalStates(states); + } + + @Override + public void configure(StateMachineTransitionConfigurer 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 states) + throws Exception { + } + + protected void configureAdditionalTransitions( + StateMachineTransitionConfigurer transitions) throws Exception { + } +} diff --git a/state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/order/config/StandardOrderStateMachineConfiguration.java b/state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/order/config/StandardOrderStateMachineConfiguration.java new file mode 100644 index 0000000..ae75a4a --- /dev/null +++ b/state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/order/config/StandardOrderStateMachineConfiguration.java @@ -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 states) + throws Exception { + states.state(OrderState.CANCELLED); + } + + @Override + protected void configureAdditionalTransitions( + StateMachineTransitionConfigurer transitions) throws Exception { + transitions + .withExternal() + .source(OrderState.PAID) + .target(OrderState.CANCELLED) + .event(OrderTransitionEvent.CANCEL); + } +} diff --git a/state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/web/CommandGateway.java b/state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/web/CommandGateway.java new file mode 100644 index 0000000..9d2620f --- /dev/null +++ b/state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/web/CommandGateway.java @@ -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); + } +} diff --git a/state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/web/DocumentController.java b/state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/web/DocumentController.java new file mode 100644 index 0000000..6e11bda --- /dev/null +++ b/state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/web/DocumentController.java @@ -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(); + } +} diff --git a/state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/web/GenericCommandController.java b/state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/web/GenericCommandController.java new file mode 100644 index 0000000..f3004b9 --- /dev/null +++ b/state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/web/GenericCommandController.java @@ -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); + } +} diff --git a/state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/web/OrderController.java b/state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/web/OrderController.java new file mode 100644 index 0000000..5fb8138 --- /dev/null +++ b/state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/web/OrderController.java @@ -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(); + } +} diff --git a/state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/web/RichOrderController.java b/state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/web/RichOrderController.java new file mode 100644 index 0000000..552dab2 --- /dev/null +++ b/state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/web/RichOrderController.java @@ -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()); + } +} diff --git a/state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/web/StringDispatchController.java b/state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/web/StringDispatchController.java new file mode 100644 index 0000000..12b1d47 --- /dev/null +++ b/state_machines/layered_dispatcher_sample/src/main/java/click/kamil/examples/statemachine/layered/web/StringDispatchController.java @@ -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"); + } +}