diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/EnumMemberPredicateEvaluator.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/EnumMemberPredicateEvaluator.java index e2ffe98..7aed29c 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/EnumMemberPredicateEvaluator.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/EnumMemberPredicateEvaluator.java @@ -95,6 +95,34 @@ public final class EnumMemberPredicateEvaluator { return filtered; } + /** + * True when every predicate method in the constraint exists on the enum (or its interfaces). + * Used to distinguish inconclusive filtering from an intentional empty result. + */ + public static boolean hasResolvablePredicateMethods( + String constraint, + String enumTypeFqn, + CodebaseContext context) { + List predicates = extractPredicateCalls(constraint); + if (predicates.isEmpty()) { + return true; + } + EnumDeclaration enumDecl = findEnumDeclaration(enumTypeFqn, context); + if (enumDecl == null) { + return false; + } + for (PredicateCall predicate : predicates) { + MethodDeclaration method = findParameterlessMethod(enumDecl, predicate.methodName()); + if (method == null && context != null) { + method = findInterfaceParameterlessMethod(enumDecl, predicate.methodName(), context); + } + if (method == null) { + return false; + } + } + return true; + } + private static boolean satisfiesPredicates( String canonicalConstantFqn, List predicates, diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizer.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizer.java index 56fcf1c..69e0950 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizer.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizer.java @@ -101,6 +101,14 @@ public final class MachineEnumCanonicalizer { List polymorphicEvents, String machineEventTypeFqn, CodebaseContext context) { + return expandSymbolicPolymorphicEvents(polymorphicEvents, machineEventTypeFqn, context, null); + } + + public static List expandSymbolicPolymorphicEvents( + List polymorphicEvents, + String machineEventTypeFqn, + CodebaseContext context, + List machineTransitions) { if (polymorphicEvents == null || polymorphicEvents.isEmpty()) { return polymorphicEvents; } @@ -108,6 +116,8 @@ public final class MachineEnumCanonicalizer { return polymorphicEvents; } + List transitionEvents = polymorphicEventsFromTransitions(machineTransitions, machineEventTypeFqn); + List expanded = new ArrayList<>(); for (String pe : polymorphicEvents) { if (pe == null) { @@ -118,6 +128,10 @@ public final class MachineEnumCanonicalizer { if (!enumTypesMatch(machineEventTypeFqn, symbolicType)) { continue; } + if (!transitionEvents.isEmpty()) { + expanded.addAll(transitionEvents); + continue; + } if (context != null) { List enumValues = context.getEnumValues(stripGenerics(machineEventTypeFqn)); if (enumValues != null) { @@ -202,7 +216,7 @@ public final class MachineEnumCanonicalizer { List machineTransitions, boolean skipCanonicalization) { TriggerPoint expanded = skipCanonicalization - ? expandSymbolicOnly(trigger, machineTypes, context) + ? expandSymbolicOnly(trigger, machineTypes, context, machineTransitions) : canonicalizeAndExpandTriggerPoint(trigger, machineTypes, context); if (expanded == null || machineTypes == null || machineTypes.eventTypeFqn() == null) { return expanded; @@ -210,7 +224,7 @@ public final class MachineEnumCanonicalizer { List postExpand = expanded.getPolymorphicEvents(); if (postExpand != null && postExpand.stream().anyMatch(pe -> pe != null && pe.startsWith(" narrowed = narrowPolymorphicCandidates( expanded.getPolymorphicEvents(), expanded.getConstraint(), @@ -255,12 +270,13 @@ public final class MachineEnumCanonicalizer { private static TriggerPoint expandSymbolicOnly( TriggerPoint trigger, StateMachineTypeResolver.MachineTypes machineTypes, - CodebaseContext context) { + CodebaseContext context, + List machineTransitions) { if (trigger == null || machineTypes == null || machineTypes.eventTypeFqn() == null) { return trigger; } List expanded = expandSymbolicPolymorphicEvents( - trigger.getPolymorphicEvents(), machineTypes.eventTypeFqn(), context); + trigger.getPolymorphicEvents(), machineTypes.eventTypeFqn(), context, machineTransitions); if (java.util.Objects.equals(expanded, trigger.getPolymorphicEvents())) { return trigger; } @@ -315,21 +331,21 @@ public final class MachineEnumCanonicalizer { List machineTransitions, CodebaseContext context) { List transitionEvents = polymorphicEventsFromTransitions(machineTransitions, eventTypeFqn); - if (!transitionEvents.isEmpty()) { - List filtered = EnumMemberPredicateEvaluator.filterEnumConstants( - transitionEvents, constraint, eventTypeFqn, context); - return filtered.isEmpty() ? transitionEvents : filtered; + if (transitionEvents.isEmpty()) { + return List.of(); } - List enumConstants = allPackageCanonicalEnumConstants(eventTypeFqn, context); List filtered = EnumMemberPredicateEvaluator.filterEnumConstants( - enumConstants, constraint, eventTypeFqn, context); + transitionEvents, constraint, eventTypeFqn, context); if (!filtered.isEmpty()) { return filtered; } - if (!transitionEvents.isEmpty()) { + if (EnumMemberPredicateEvaluator.hasEnumMemberPredicates(constraint)) { + if (EnumMemberPredicateEvaluator.hasResolvablePredicateMethods(constraint, eventTypeFqn, context)) { + return List.of(); + } return transitionEvents; } - return List.of(); + return transitionEvents; } private static List narrowPolymorphicCandidates( @@ -339,56 +355,65 @@ public final class MachineEnumCanonicalizer { List machineTransitions, CodebaseContext context) { List transitionEvents = polymorphicEventsFromTransitions(machineTransitions, eventTypeFqn); - List result = EnumMemberPredicateEvaluator.filterEnumConstants( - current, constraint, eventTypeFqn, context); - if (result.isEmpty() && !transitionEvents.isEmpty()) { - result = new ArrayList<>(transitionEvents); - } - if (!looksOverBroad(result, transitionEvents, constraint)) { - return result; - } - if (!transitionEvents.isEmpty()) { - List intersected = new ArrayList<>(); - for (String event : result) { - if (transitionEvents.contains(event)) { - intersected.add(event); + List result = current == null ? List.of() : new ArrayList<>(current); + + if (EnumMemberPredicateEvaluator.hasEnumMemberPredicates(constraint)) { + List filtered = EnumMemberPredicateEvaluator.filterEnumConstants( + result, constraint, eventTypeFqn, context); + if (!filtered.isEmpty()) { + result = filtered; + } else if (!transitionEvents.isEmpty()) { + filtered = EnumMemberPredicateEvaluator.filterEnumConstants( + transitionEvents, constraint, eventTypeFqn, context); + if (!filtered.isEmpty()) { + result = filtered; + } else if (EnumMemberPredicateEvaluator.hasResolvablePredicateMethods( + constraint, eventTypeFqn, context)) { + result = List.of(); + } else { + result = transitionEvents; } + } else { + result = List.of(); } - if (!intersected.isEmpty()) { - return intersected; - } - List transitionFiltered = EnumMemberPredicateEvaluator.filterEnumConstants( - transitionEvents, constraint, eventTypeFqn, context); - return transitionFiltered.isEmpty() ? transitionEvents : transitionFiltered; + } + + if (!transitionEvents.isEmpty()) { + result = capToConfiguredTransitionEvents(result, transitionEvents); } return result; } - private static boolean looksOverBroad( - List current, - List transitionEvents, - String constraint) { - if (current == null || current.isEmpty()) { - return false; + /** + * Fail-closed ceiling: polymorphic candidates must never exceed events configured on the machine. + */ + static List capToConfiguredTransitionEvents( + List candidates, + List transitionEvents) { + if (transitionEvents == null || transitionEvents.isEmpty()) { + return candidates == null ? List.of() : candidates; } - if (EnumMemberPredicateEvaluator.hasEnumMemberPredicates(constraint)) { - return true; + if (candidates == null || candidates.isEmpty()) { + return List.of(); } - if (current.size() <= 1) { - return false; - } - if (transitionEvents.isEmpty()) { - return current.size() > 1; - } - if (current.size() > transitionEvents.size()) { - return true; - } - for (String event : current) { - if (!transitionEvents.contains(event)) { - return true; + List capped = new ArrayList<>(); + for (String candidate : candidates) { + if (candidate == null) { + continue; + } + if (transitionEvents.contains(candidate)) { + capped.add(candidate); + continue; + } + String constant = constantName(candidate); + for (String transitionEvent : transitionEvents) { + if (constantName(transitionEvent).equals(constant)) { + capped.add(transitionEvent); + break; + } } } - return false; + return capped; } public static List polymorphicEventsFromTransitions( @@ -423,23 +448,6 @@ public final class MachineEnumCanonicalizer { .anyMatch(pe -> classifyTriggerEvent(pe) == TriggerEventKind.CANONICAL_ENUM); } - private static List allPackageCanonicalEnumConstants(String eventTypeFqn, CodebaseContext context) { - if (eventTypeFqn == null || eventTypeFqn.isBlank() || context == null) { - return List.of(); - } - String enumType = stripGenerics(eventTypeFqn); - List enumValues = context.getEnumValues(enumType); - if (enumValues == null || enumValues.isEmpty()) { - return List.of(); - } - List canonical = new ArrayList<>(); - for (String value : enumValues) { - String constant = value.contains(".") ? value.substring(value.lastIndexOf('.') + 1) : value; - canonical.add(enumType + "." + constant); - } - return canonical; - } - public static String qualifyEventIdentifier(String event, String eventTypeFqn) { if (event == null || eventTypeFqn == null || event.isEmpty()) { return event; 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 dc72f18..45c47d4 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 @@ -971,14 +971,6 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { polymorphicEvents.add(methodReturn); } } - if (!hasConcreteEnumConstants(polymorphicEvents, declaredType, context) - && polymorphicEvents.stream().anyMatch(pe -> pe != null && pe.startsWith(" expandedEnumValues = expandDeclaredEnumValues(declaredType); - if (!expandedEnumValues.isEmpty()) { - polymorphicEvents = new ArrayList<>(expandedEnumValues); - } - } } } } @@ -2403,26 +2395,16 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { if (tdOuter != null) { String callerFqn = context.getFqn(tdOuter) + "." + md.getName().getIdentifier(); String methodName = node.getName().getIdentifier(); - TypeDeclaration td = findEnclosingType(node); - if (td != null) { - String superFqn = context.getSuperclassFqn(td); - if (superFqn != null) { - TypeDeclaration superTd = context.getTypeDeclaration(superFqn); - String calledMethod = null; - if (superTd != null) { - calledMethod = resolveMethodInType(superTd, methodName); - } - if (calledMethod == null) { - calledMethod = superFqn + "." + methodName; - } - List args = resolveArguments(node.arguments()); - String receiver = "super"; - String constraint = click.kamil.springstatemachineexporter.ast.common.AstUtils - .findConditionConstraint(node); - CallEdge edge = new CallEdge(calledMethod, args, receiver); - edge.setConstraint(constraint); - graph.computeIfAbsent(callerFqn, k -> new ArrayList<>()).add(edge); - } + String calledMethod = InheritanceCallTargetResolver.resolveSuperMethod( + context, tdOuter, methodName); + if (calledMethod != null) { + List args = resolveArguments(node.arguments()); + String receiver = "super"; + String constraint = click.kamil.springstatemachineexporter.ast.common.AstUtils + .findConditionConstraint(node); + CallEdge edge = new CallEdge(calledMethod, args, receiver); + edge.setConstraint(constraint); + graph.computeIfAbsent(callerFqn, k -> new ArrayList<>()).add(edge); } } } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/HeuristicCallGraphEngine.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/HeuristicCallGraphEngine.java index ce1509a..df471fd 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/HeuristicCallGraphEngine.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/HeuristicCallGraphEngine.java @@ -28,12 +28,14 @@ public class HeuristicCallGraphEngine extends AbstractCallGraphEngine { } } + TypeDeclaration enclosingType = findEnclosingType(node); + + if (receiver instanceof SuperMethodInvocation) { + return InheritanceCallTargetResolver.resolveSuperMethod(context, enclosingType, methodName); + } + if (receiver == null) { - TypeDeclaration td = findEnclosingType(node); - if (td != null) { - return resolveMethodInTypeHierarchy(td, methodName); - } - return null; + return InheritanceCallTargetResolver.resolveInstanceMethod(context, enclosingType, methodName); } ITypeBinding binding = receiver.resolveTypeBinding(); @@ -42,10 +44,7 @@ public class HeuristicCallGraphEngine extends AbstractCallGraphEngine { } if (receiver instanceof ThisExpression) { - TypeDeclaration td = findEnclosingType(node); - if (td != null) { - return context.getFqn(td) + "." + methodName; - } + return InheritanceCallTargetResolver.resolveInstanceMethod(context, enclosingType, methodName); } if (receiver instanceof SimpleName sn) { diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/InheritanceCallTargetResolver.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/InheritanceCallTargetResolver.java new file mode 100644 index 0000000..1ae7f63 --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/InheritanceCallTargetResolver.java @@ -0,0 +1,65 @@ +package click.kamil.springstatemachineexporter.analysis.service; + +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import org.eclipse.jdt.core.dom.ASTNode; +import org.eclipse.jdt.core.dom.MethodDeclaration; +import org.eclipse.jdt.core.dom.TypeDeclaration; + +/** + * Resolves call-graph target FQNs for {@code this}, implicit, and {@code super} dispatch hops. + * Uses the declaring type of the resolved method (parent class for inherited members). + */ +final class InheritanceCallTargetResolver { + + private InheritanceCallTargetResolver() { + } + + static String resolveInstanceMethod( + CodebaseContext context, + TypeDeclaration enclosingType, + String methodName) { + if (context == null || enclosingType == null || methodName == null || methodName.isBlank()) { + return null; + } + MethodDeclaration method = context.findMethodDeclaration(enclosingType, methodName, true); + if (method == null) { + return null; + } + TypeDeclaration declaringType = findEnclosingTypeDeclaration(method); + if (declaringType == null) { + return null; + } + String fqn = context.getFqn(declaringType); + return fqn == null ? null : fqn + "." + methodName; + } + + static String resolveSuperMethod( + CodebaseContext context, + TypeDeclaration enclosingType, + String methodName) { + if (context == null || enclosingType == null || methodName == null || methodName.isBlank()) { + return null; + } + String superFqn = context.getSuperclassFqn(enclosingType); + if (superFqn == null) { + return null; + } + TypeDeclaration superType = context.getTypeDeclaration(superFqn); + if (superType == null) { + return superFqn + "." + methodName; + } + String resolved = resolveInstanceMethod(context, superType, methodName); + return resolved != null ? resolved : superFqn + "." + methodName; + } + + private static TypeDeclaration findEnclosingTypeDeclaration(ASTNode node) { + ASTNode current = node; + while (current != null) { + if (current instanceof TypeDeclaration typeDeclaration) { + return typeDeclaration; + } + current = current.getParent(); + } + return null; + } +} diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/JdtCallGraphEngine.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/JdtCallGraphEngine.java index a7bb4ab..b162927 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/JdtCallGraphEngine.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/JdtCallGraphEngine.java @@ -56,12 +56,14 @@ public class JdtCallGraphEngine extends AbstractCallGraphEngine { } } + TypeDeclaration enclosingType = findEnclosingType(node); + + if (receiver instanceof SuperMethodInvocation) { + return InheritanceCallTargetResolver.resolveSuperMethod(context, enclosingType, methodName); + } + if (receiver == null) { - TypeDeclaration td = findEnclosingType(node); - if (td != null) { - return resolveMethodInType(td, methodName); - } - return null; + return InheritanceCallTargetResolver.resolveInstanceMethod(context, enclosingType, methodName); } if (injectionAnalyzer != null) { @@ -122,10 +124,7 @@ public class JdtCallGraphEngine extends AbstractCallGraphEngine { } if (receiver instanceof ThisExpression) { - TypeDeclaration td = findEnclosingType(node); - if (td != null) { - return context.getFqn(td) + "." + methodName; - } + return InheritanceCallTargetResolver.resolveInstanceMethod(context, enclosingType, methodName); } if (receiver instanceof SimpleName sn) { diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizerTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizerTest.java index 5d0a49b..4d2a85a 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizerTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizerTest.java @@ -247,6 +247,51 @@ class MachineEnumCanonicalizerTest { .isEqualTo("com.example.order.OrderEvent.PAY"); } + @Test + void shouldCapFullEnumPolymorphicListToConfiguredTransitionsOnly() { + Transition pay = new Transition(); + pay.setEvent(Event.of("OrderEvent.PAY", "com.example.order.OrderEvent.PAY")); + Transition ship = new Transition(); + ship.setEvent(Event.of("OrderEvent.SHIP", "com.example.order.OrderEvent.SHIP")); + + List fullEnum = List.of( + "com.example.order.OrderEvent.PAY", + "com.example.order.OrderEvent.SHIP", + "com.example.order.OrderEvent.CANCEL", + "com.example.order.OrderEvent.LOG"); + + TriggerPoint trigger = TriggerPoint.builder() + .event("eventType") + .polymorphicEvents(fullEnum) + .external(true) + .build(); + + StateMachineTypeResolver.MachineTypes types = new StateMachineTypeResolver.MachineTypes( + "com.example.order.OrderState", "com.example.order.OrderEvent"); + + TriggerPoint linked = MachineEnumCanonicalizer.ensureCallChainPolymorphicEvents( + trigger, types, null, List.of(pay, ship)); + + assertThat(linked.getPolymorphicEvents()) + .containsExactlyInAnyOrder( + "com.example.order.OrderEvent.PAY", + "com.example.order.OrderEvent.SHIP"); + } + + @Test + void shouldExpandSymbolicPolymorphicEventsToConfiguredTransitionsWhenAvailable() { + Transition pay = new Transition(); + pay.setEvent(Event.of("OrderEvent.PAY", "com.example.order.OrderEvent.PAY")); + + List expanded = MachineEnumCanonicalizer.expandSymbolicPolymorphicEvents( + List.of(""), + "com.example.order.OrderEvent", + null, + List.of(pay)); + + assertThat(expanded).containsExactly("com.example.order.OrderEvent.PAY"); + } + @Test void shouldInferPolymorphicEventsFromTransitionsWhenContextIsUnavailable() { Transition pay = new Transition(); 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 index f3cad0d..882f331 100644 --- 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 @@ -7,6 +7,7 @@ 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.analysis.resolver.MachineEnumCanonicalizer; import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; import click.kamil.springstatemachineexporter.model.Event; import click.kamil.springstatemachineexporter.model.State; @@ -43,7 +44,7 @@ final class CentralDispatcherTestSupport { String controllerClass, String controllerMethod, String stateMachineClass) { - return resolveChain(context, controllerClass, controllerMethod, stateMachineClass, EngineKind.HEURISTIC); + return resolveChain(context, controllerClass, controllerMethod, stateMachineClass, "sendEvent", EngineKind.HEURISTIC); } static CallChain resolveChain( @@ -52,26 +53,64 @@ final class CentralDispatcherTestSupport { String controllerMethod, String stateMachineClass, EngineKind engineKind) { + return resolveChain(context, controllerClass, controllerMethod, stateMachineClass, "sendEvent", engineKind); + } + + static CallChain resolveChain( + CodebaseContext context, + String entryClass, + String entryMethod, + String triggerClass, + String triggerMethod, + EngineKind engineKind) { AbstractCallGraphEngine engine = engineKind == EngineKind.JDT ? new JdtCallGraphEngine(context, null) : new HeuristicCallGraphEngine(context); EntryPoint entryPoint = EntryPoint.builder() - .className(controllerClass) - .methodName(controllerMethod) + .className(entryClass) + .methodName(entryMethod) .build(); TriggerPoint trigger = TriggerPoint.builder() - .className(stateMachineClass) - .methodName("sendEvent") + .className(triggerClass) + .methodName(triggerMethod) .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 via %s", - controllerClass, controllerMethod, stateMachineClass, engineKind) + .as("expected one call chain from %s.%s to %s.%s via %s", + entryClass, entryMethod, triggerClass, triggerMethod, engineKind) .hasSize(1); return chains.get(0); } + static void assertParity( + CodebaseContext context, + String entryClass, + String entryMethod, + String triggerClass, + String triggerMethod) { + CallChain heuristic = resolveChain( + context, entryClass, entryMethod, triggerClass, triggerMethod, EngineKind.HEURISTIC); + CallChain jdt = resolveChain( + context, entryClass, entryMethod, triggerClass, triggerMethod, EngineKind.JDT); + assertThat(jdt.getMethodChain()) + .containsExactlyElementsOf(heuristic.getMethodChain()); + assertThat(jdt.getTriggerPoint().getEvent()).isEqualTo(heuristic.getTriggerPoint().getEvent()); + assertThat(jdt.getTriggerPoint().getPolymorphicEvents()) + .containsExactlyInAnyOrderElementsOf( + heuristic.getTriggerPoint().getPolymorphicEvents() == null + ? List.of() + : heuristic.getTriggerPoint().getPolymorphicEvents()); + } + + static void assertMethodChainContains(CallChain chain, String... methodFqns) { + assertThat(chain.getMethodChain()).contains(methodFqns); + } + + static void assertMethodChainContainsExact(CallChain chain, String... methodFqns) { + assertThat(chain.getMethodChain()).containsExactly(methodFqns); + } + static void assertPolyEvents(CallChain chain, String... expectedEvents) { assertThat(chain.getTriggerPoint().getPolymorphicEvents()) .containsExactlyInAnyOrder(expectedEvents); @@ -105,4 +144,76 @@ final class CentralDispatcherTestSupport { static void assertLinkedEvent(MatchedTransition matched, String expectedEvent) { assertThat(matched.getEvent()).isEqualTo(expectedEvent); } + + static CallChain linkChain( + CodebaseContext context, + CallChain rawChain, + String machineConfig, + String eventTypeFqn, + String stateTypeFqn, + Transition... machineTransitions) { + AnalysisResult result = AnalysisResult.builder() + .name(machineConfig) + .eventTypeFqn(eventTypeFqn) + .stateTypeFqn(stateTypeFqn) + .transitions(List.of(machineTransitions)) + .metadata(CodebaseMetadata.builder().callChains(List.of(rawChain)).build()) + .build(); + new TransitionLinkerEnricher().enrich(result, context, null); + return result.getMetadata().getCallChains().get(0); + } + + static CallChain resolveLinkAndAssertSingleMatch( + CodebaseContext context, + String entryClass, + String entryMethod, + String triggerClass, + String triggerMethod, + String machineConfig, + String eventTypeFqn, + String stateTypeFqn, + String expectedLinkedEvent, + EngineKind engineKind, + Transition... machineTransitions) { + CallChain raw = resolveChain(context, entryClass, entryMethod, triggerClass, triggerMethod, engineKind); + CallChain linked = linkChain(context, raw, machineConfig, eventTypeFqn, stateTypeFqn, machineTransitions); + assertPolyWithinTransitions(linked, machineTransitions.length); + assertMatchedWithinTransitions(linked, machineTransitions.length); + assertThat(linked.getMatchedTransitions()) + .as("expected exactly one matched transition") + .hasSize(1); + assertLinkedEvent(linked.getMatchedTransitions().get(0), expectedLinkedEvent); + return linked; + } + + static void assertPolyWithinTransitions(CallChain chain, int configuredTransitionCount) { + List poly = chain.getTriggerPoint().getPolymorphicEvents(); + assertThat(poly) + .as("polymorphicEvents must be present after linking") + .isNotNull(); + assertThat(poly.size()) + .as("polymorphicEvents must not exceed configured transition count") + .isLessThanOrEqualTo(configuredTransitionCount); + assertThat(MachineEnumCanonicalizer.hasOnlyConcretePolymorphicEvents(poly)).isTrue(); + } + + static void assertMatchedWithinTransitions(CallChain chain, int configuredTransitionCount) { + assertThat(chain.getMatchedTransitions()) + .as("matchedTransitions should be populated after linking") + .isNotNull(); + assertThat(chain.getMatchedTransitions().size()) + .as("matchedTransitions must not exceed configured transition count") + .isLessThanOrEqualTo(configuredTransitionCount); + } + + static void assertPolyCappedToTransitionEvents( + CallChain chain, + String eventTypeFqn, + Transition... machineTransitions) { + List allowed = MachineEnumCanonicalizer.polymorphicEventsFromTransitions( + List.of(machineTransitions), eventTypeFqn); + assertThat(chain.getTriggerPoint().getPolymorphicEvents()) + .isNotNull() + .allSatisfy(pe -> assertThat(allowed).contains(pe)); + } } diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/DeepDispatcherHierarchyTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/DeepDispatcherHierarchyTest.java new file mode 100644 index 0000000..6fb17a9 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/DeepDispatcherHierarchyTest.java @@ -0,0 +1,174 @@ +package click.kamil.springstatemachineexporter.analysis.service; + +import click.kamil.springstatemachineexporter.analysis.model.CallChain; +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import click.kamil.springstatemachineexporter.model.Transition; +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; + +/** + * Deep inheritance dispatcher stacks: multi-level {@code super} and template-method patterns. + */ +class DeepDispatcherHierarchyTest { + + private static final String MACHINE_CONFIG = "com.example.OrderStateMachineConfig"; + private static final String EVENT_TYPE = "com.example.OrderEvent"; + private static final String STATE_TYPE = "com.example.OrderState"; + + @Test + void jdtShouldMatchHeuristicForMultiLevelSuperDelegation(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + public class ApiController { + GrandChildHandler handler; + public void pay() { handler.entry(OrderEvent.PAY); } + } + class GrandChildHandler extends MiddleHandler { + void entry(OrderEvent event) { super.route(event); } + } + class MiddleHandler extends BaseHandler { + protected void route(OrderEvent event) { super.dispatch(event); } + } + class BaseHandler { + StateMachine machine; + protected void dispatch(OrderEvent event) { machine.sendEvent(event); } + } + class StateMachine { public void sendEvent(OrderEvent e) {} } + enum OrderEvent { PAY, SHIP, LOG, META } + """; + CodebaseContext context = scanSource(source, tempDir); + + assertParity(context, "com.example.ApiController", "pay", "com.example.StateMachine", "sendEvent"); + + CallChain chain = resolveChain( + context, "com.example.ApiController", "pay", "com.example.StateMachine", "sendEvent", EngineKind.JDT); + assertMethodChainContains(chain, + "com.example.GrandChildHandler.entry", + "com.example.MiddleHandler.route", + "com.example.BaseHandler.dispatch"); + assertPolyEvents(chain, "OrderEvent.PAY"); + } + + @Test + void jdtShouldMatchHeuristicForTemplateMethodProtectedDispatch(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + public class ApiController { + OrderHandler handler; + public void pay() { handler.pay(); } + } + abstract class AbstractHandler { + StateMachine machine; + public void pay() { dispatch(buildPayEvent()); } + protected abstract OrderEvent buildPayEvent(); + protected void dispatch(OrderEvent event) { machine.sendEvent(event); } + } + class OrderHandler extends AbstractHandler { + @Override protected OrderEvent buildPayEvent() { return OrderEvent.PAY; } + } + class StateMachine { public void sendEvent(OrderEvent e) {} } + enum OrderEvent { PAY, SHIP, LOG, META } + """; + CodebaseContext context = scanSource(source, tempDir); + + assertParity(context, "com.example.ApiController", "pay", "com.example.StateMachine", "sendEvent"); + assertPolyEvents( + resolveChain(context, "com.example.ApiController", "pay", "com.example.StateMachine", "sendEvent", EngineKind.HEURISTIC), + "OrderEvent.PAY"); + } + + @Test + void pipelineShouldLinkMultiLevelSuperToSingleConfiguredTransition(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + public class ApiController { + GrandChildHandler handler; + public void pay() { handler.entry(OrderEvent.PAY); } + } + class GrandChildHandler extends MiddleHandler { + void entry(OrderEvent event) { super.route(event); } + } + class MiddleHandler extends BaseHandler { + protected void route(OrderEvent event) { super.dispatch(event); } + } + class BaseHandler { + StateMachine machine; + protected void dispatch(OrderEvent event) { machine.sendEvent(event); } + } + class StateMachine { public void sendEvent(OrderEvent e) {} } + enum OrderEvent { PAY, SHIP, LOG, META } + class OrderStateMachineConfig {} + """; + CodebaseContext context = scanSource(source, tempDir); + + Transition pay = transition("NEW", "PAID", EVENT_TYPE + ".PAY"); + Transition ship = transition("PAID", "SHIPPED", EVENT_TYPE + ".SHIP"); + + CallChain linked = resolveLinkAndAssertSingleMatch( + context, + "com.example.ApiController", + "pay", + "com.example.StateMachine", + "sendEvent", + MACHINE_CONFIG, + EVENT_TYPE, + STATE_TYPE, + EVENT_TYPE + ".PAY", + EngineKind.JDT, + pay, + ship); + + assertPolyCappedToTransitionEvents(linked, EVENT_TYPE, pay, ship); + assertThat(linked.getTriggerPoint().getPolymorphicEvents()) + .containsExactly(EVENT_TYPE + ".PAY"); + } + + @Test + void pipelineShouldLinkTemplateMethodDispatchToSingleConfiguredTransition(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + public class ApiController { + OrderHandler handler; + public void pay() { handler.pay(); } + } + abstract class AbstractHandler { + StateMachine machine; + public void pay() { dispatch(buildPayEvent()); } + protected abstract OrderEvent buildPayEvent(); + protected void dispatch(OrderEvent event) { machine.sendEvent(event); } + } + class OrderHandler extends AbstractHandler { + @Override protected OrderEvent buildPayEvent() { return OrderEvent.PAY; } + } + class StateMachine { public void sendEvent(OrderEvent e) {} } + enum OrderEvent { PAY, SHIP, LOG, META } + class OrderStateMachineConfig {} + """; + CodebaseContext context = scanSource(source, tempDir); + + Transition pay = transition("NEW", "PAID", EVENT_TYPE + ".PAY"); + Transition ship = transition("PAID", "SHIPPED", EVENT_TYPE + ".SHIP"); + + CallChain linked = resolveLinkAndAssertSingleMatch( + context, + "com.example.ApiController", + "pay", + "com.example.StateMachine", + "sendEvent", + MACHINE_CONFIG, + EVENT_TYPE, + STATE_TYPE, + EVENT_TYPE + ".PAY", + EngineKind.HEURISTIC, + pay, + ship); + + assertPolyCappedToTransitionEvents(linked, EVENT_TYPE, pay, ship); + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/DispatcherPolyCeilingPipelineTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/DispatcherPolyCeilingPipelineTest.java new file mode 100644 index 0000000..7b8c002 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/DispatcherPolyCeilingPipelineTest.java @@ -0,0 +1,122 @@ +package click.kamil.springstatemachineexporter.analysis.service; + +import click.kamil.springstatemachineexporter.analysis.model.CallChain; +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.Transition; +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; + +/** + * Ensures linker-stage polymorphic narrowing caps over-broad call-graph enum lists to configured transitions. + */ +class DispatcherPolyCeilingPipelineTest { + + private static final String MACHINE_CONFIG = "com.example.OrderStateMachineConfig"; + private static final String EVENT_TYPE = "com.example.OrderEvent"; + private static final String STATE_TYPE = "com.example.OrderState"; + + @Test + void linkerShouldCapInjectedFullEnumPolymorphicListToConfiguredTransitions(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + public class ApiController { + CentralDispatcher dispatcher; + public void pay() { dispatcher.route("PAY"); } + } + class CentralDispatcher { + StateMachine machine; + public void route(String action) { machine.sendEvent(OrderEvent.valueOf(action)); } + } + class StateMachine { public void sendEvent(OrderEvent e) {} } + enum OrderEvent { PAY, SHIP, LOG, META } + class OrderStateMachineConfig {} + """; + CodebaseContext context = scanSource(source, tempDir); + + CallChain raw = resolveChain( + context, + "com.example.ApiController", + "pay", + "com.example.StateMachine", + "sendEvent", + EngineKind.JDT); + + List bloatedEnum = List.of( + EVENT_TYPE + ".PAY", + EVENT_TYPE + ".SHIP", + EVENT_TYPE + ".LOG", + EVENT_TYPE + ".META"); + + TriggerPoint bloatedTrigger = raw.getTriggerPoint().toBuilder() + .polymorphicEvents(bloatedEnum) + .external(true) + .build(); + CallChain bloatedChain = raw.toBuilder().triggerPoint(bloatedTrigger).build(); + + Transition pay = transition("NEW", "PAID", EVENT_TYPE + ".PAY"); + Transition ship = transition("PAID", "SHIPPED", EVENT_TYPE + ".SHIP"); + + CallChain linked = linkChain( + context, bloatedChain, MACHINE_CONFIG, EVENT_TYPE, STATE_TYPE, pay, ship); + + assertPolyWithinTransitions(linked, 2); + assertPolyCappedToTransitionEvents(linked, EVENT_TYPE, pay, ship); + assertThat(linked.getTriggerPoint().getPolymorphicEvents()) + .containsExactlyInAnyOrder(EVENT_TYPE + ".PAY", EVENT_TYPE + ".SHIP"); + assertMatchedWithinTransitions(linked, 2); + assertThat(linked.getMatchedTransitions()) + .extracting(MatchedTransition::getEvent) + .containsExactlyInAnyOrder(EVENT_TYPE + ".PAY", EVENT_TYPE + ".SHIP"); + } + + @Test + void linkerShouldCapCallGraphPolyForDeepDispatcherToTransitionCeiling(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + public class ApiController { + OrderService orderService; + public void pay() { orderService.handlePay(); } + } + class OrderService { + CentralDispatcher dispatcher; + protected void handlePay() { dispatcher.route("PAY"); } + } + class CentralDispatcher { + StateMachine machine; + public void route(String action) { machine.sendEvent(OrderEvent.valueOf(action)); } + } + class StateMachine { public void sendEvent(OrderEvent e) {} } + enum OrderEvent { PAY, SHIP, LOG, META } + class OrderStateMachineConfig {} + """; + CodebaseContext context = scanSource(source, tempDir); + + Transition pay = transition("NEW", "PAID", EVENT_TYPE + ".PAY"); + Transition ship = transition("PAID", "SHIPPED", EVENT_TYPE + ".SHIP"); + + CallChain linked = resolveLinkAndAssertSingleMatch( + context, + "com.example.ApiController", + "pay", + "com.example.StateMachine", + "sendEvent", + MACHINE_CONFIG, + EVENT_TYPE, + STATE_TYPE, + EVENT_TYPE + ".PAY", + EngineKind.JDT, + pay, + ship); + + assertThat(linked.getTriggerPoint().getPolymorphicEvents().size()).isLessThan(4); + assertPolyCappedToTransitionEvents(linked, EVENT_TYPE, pay, ship); + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/InheritanceCallTargetResolverTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/InheritanceCallTargetResolverTest.java new file mode 100644 index 0000000..1189cb9 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/InheritanceCallTargetResolverTest.java @@ -0,0 +1,58 @@ +package click.kamil.springstatemachineexporter.analysis.service; + +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import org.eclipse.jdt.core.dom.MethodDeclaration; +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 static org.assertj.core.api.Assertions.assertThat; + +class InheritanceCallTargetResolverTest { + + @Test + void shouldResolveInheritedMethodOnDeclaringParentType(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + public class Child extends Parent { + void run() { dispatch(); } + } + class Parent { + protected void dispatch() {} + } + """; + Files.writeString(tempDir.resolve("App.java"), source); + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + + TypeDeclaration child = context.getTypeDeclaration("com.example.Child"); + MethodDeclaration run = context.findMethodDeclaration(child, "run", false); + + String target = InheritanceCallTargetResolver.resolveInstanceMethod(context, child, "dispatch"); + assertThat(target).isEqualTo("com.example.Parent.dispatch"); + assertThat(run).isNotNull(); + } + + @Test + void shouldResolveSuperCallToParentMethod(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + public class Child extends Parent { + void run() { super.dispatch(); } + } + class Parent { + protected void dispatch() {} + } + """; + Files.writeString(tempDir.resolve("App.java"), source); + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + + TypeDeclaration child = context.getTypeDeclaration("com.example.Child"); + String target = InheritanceCallTargetResolver.resolveSuperMethod(context, child, "dispatch"); + assertThat(target).isEqualTo("com.example.Parent.dispatch"); + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/JdtInheritanceDispatcherParityTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/JdtInheritanceDispatcherParityTest.java new file mode 100644 index 0000000..396e0b7 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/JdtInheritanceDispatcherParityTest.java @@ -0,0 +1,185 @@ +package click.kamil.springstatemachineexporter.analysis.service; + +import click.kamil.springstatemachineexporter.analysis.model.CallChain; +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 static click.kamil.springstatemachineexporter.analysis.service.CentralDispatcherTestSupport.*; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Heuristic vs JDT parity for inheritance dispatcher patterns ({@code super}, protected, deep chains). + */ +class JdtInheritanceDispatcherParityTest { + + private static final String API = "com.example.ApiEndpoint"; + private static final String CONTROLLER = "com.example.ChildController"; + private static final String STATE_MACHINE = "com.example.StateMachine"; + + @Test + void jdtShouldMatchHeuristicForSuperOverrideSwitchDelegation(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + public class ApiEndpoint { + CustomHandler handler; + public void triggerCheckout() { handler.handleEvent(ActionType.BEGIN_CHECKOUT); } + } + abstract class BaseHandler { + StateMachine machine; + public void handleEvent(ActionType action) { + TransitionEnum transition = switch (action) { + case BEGIN_CHECKOUT -> TransitionEnum.CHECKOUT_STARTED; + case ABORT_CHECKOUT -> TransitionEnum.CHECKOUT_CANCELLED; + }; + machine.fire(transition); + } + } + class CustomHandler extends BaseHandler { + @Override + public void handleEvent(ActionType action) { + if (action == null) return; + super.handleEvent(action); + } + } + class StateMachine { public void fire(TransitionEnum event) {} } + enum ActionType { BEGIN_CHECKOUT, ABORT_CHECKOUT } + enum TransitionEnum { CHECKOUT_STARTED, CHECKOUT_CANCELLED } + """; + CodebaseContext context = scanSource(source, tempDir); + + assertParity(context, API, "triggerCheckout", STATE_MACHINE, "fire"); + + CallChain chain = resolveChain(context, API, "triggerCheckout", STATE_MACHINE, "fire", EngineKind.HEURISTIC); + assertMethodChainContains(chain, + "com.example.ApiEndpoint.triggerCheckout", + "com.example.CustomHandler.handleEvent", + "com.example.BaseHandler.handleEvent"); + assertPolyEvents(chain, "TransitionEnum.CHECKOUT_STARTED"); + } + + @Test + void jdtShouldMatchHeuristicForImplicitInheritedProtectedMethod(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + public class ChildController extends ParentController { + StateMachine machine; + public void process() { machine.fire(getInheritedEvent()); } + } + abstract class ParentController { + protected TransitionEnum getInheritedEvent() { return TransitionEnum.STATE_P; } + } + class StateMachine { public void fire(TransitionEnum event) {} } + enum TransitionEnum { STATE_P, STATE_Q } + """; + CodebaseContext context = scanSource(source, tempDir); + + assertParity(context, CONTROLLER, "process", STATE_MACHINE, "fire"); + assertPolyEvents( + resolveChain(context, CONTROLLER, "process", STATE_MACHINE, "fire", EngineKind.JDT), + "TransitionEnum.STATE_P"); + } + + @Test + void jdtShouldMatchHeuristicForThisCallToInheritedProtectedMethod(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + public class ChildController extends ParentController { + StateMachine machine; + public void process() { machine.fire(this.getInheritedEvent()); } + } + abstract class ParentController { + protected TransitionEnum getInheritedEvent() { return TransitionEnum.STATE_P; } + } + class StateMachine { public void fire(TransitionEnum event) {} } + enum TransitionEnum { STATE_P, STATE_Q } + """; + CodebaseContext context = scanSource(source, tempDir); + + assertParity(context, CONTROLLER, "process", STATE_MACHINE, "fire"); + assertPolyEvents( + resolveChain(context, CONTROLLER, "process", STATE_MACHINE, "fire", EngineKind.JDT), + "TransitionEnum.STATE_P"); + } + + @Test + void jdtShouldMatchHeuristicForSuperProtectedSendHelper(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + public class ChildController extends BaseController { + public void trigger() { super.send("MY_EVENT"); } + } + abstract class BaseController { + StateMachine machine; + protected void send(String event) { machine.sendEvent(event); } + } + class StateMachine { public void sendEvent(String event) {} } + """; + CodebaseContext context = scanSource(source, tempDir); + + assertParity(context, "com.example.ChildController", "trigger", "com.example.StateMachine", "sendEvent"); + CallChain chain = resolveChain( + context, "com.example.ChildController", "trigger", "com.example.StateMachine", "sendEvent", EngineKind.JDT); + assertMethodChainContains(chain, + "com.example.ChildController.trigger", + "com.example.BaseController.send"); + assertThat(chain.getTriggerPoint().getEvent()).isEqualTo("MY_EVENT"); + } + + @Test + void jdtShouldMatchHeuristicForSuperGetterArgument(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + public class ChildController extends ParentController { + StateMachine machine; + @Override public TransitionEnum getEvent() { return TransitionEnum.STATE_X; } + public void process() { machine.fire(super.getEvent()); } + } + class ParentController { + public TransitionEnum getEvent() { return TransitionEnum.STATE_Y; } + } + class StateMachine { public void fire(TransitionEnum event) {} } + enum TransitionEnum { STATE_X, STATE_Y } + """; + CodebaseContext context = scanSource(source, tempDir); + + assertParity(context, CONTROLLER, "process", STATE_MACHINE, "fire"); + assertPolyEvents( + resolveChain(context, CONTROLLER, "process", STATE_MACHINE, "fire", EngineKind.JDT), + "TransitionEnum.STATE_Y"); + } + + @Test + void jdtShouldMatchHeuristicForControllerServiceCentralDispatcher(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + public class ApiController { + OrderService orderService; + public void pay() { orderService.handlePay(); } + } + class OrderService { + CentralDispatcher dispatcher; + protected void handlePay() { dispatcher.route("PAY"); } + } + class CentralDispatcher { + StateMachine machine; + public void route(String action) { + machine.sendEvent(OrderEvent.valueOf(action)); + } + } + class StateMachine { public void sendEvent(OrderEvent e) {} } + enum OrderEvent { PAY, SHIP } + """; + CodebaseContext context = scanSource(source, tempDir); + + assertParity(context, "com.example.ApiController", "pay", "com.example.StateMachine", "sendEvent"); + CallChain chain = resolveChain( + context, "com.example.ApiController", "pay", "com.example.StateMachine", "sendEvent", EngineKind.JDT); + assertMethodChainContains(chain, + "com.example.ApiController.pay", + "com.example.OrderService.handlePay", + "com.example.CentralDispatcher.route"); + } +} diff --git a/state_machine_exporter/src/test/resources/golden/UserStateMachineConfiguration/UserStateMachineConfiguration.json b/state_machine_exporter/src/test/resources/golden/UserStateMachineConfiguration/UserStateMachineConfiguration.json index dab184e..695a068 100644 --- a/state_machine_exporter/src/test/resources/golden/UserStateMachineConfiguration/UserStateMachineConfiguration.json +++ b/state_machine_exporter/src/test/resources/golden/UserStateMachineConfiguration/UserStateMachineConfiguration.json @@ -154,7 +154,7 @@ "polymorphicEvents" : [ "click.kamil.enterprise.machines.user.UserEvent.REGISTER", "click.kamil.enterprise.machines.user.UserEvent.VERIFY" ], "external" : true, "constraint" : "!(\"ORDER\".equalsIgnoreCase(machineType)) && !(\"DOCUMENT\".equalsIgnoreCase(machineType)) && \"USER\".equalsIgnoreCase(machineType)", - "ambiguous" : true + "ambiguous" : false }, "contextMachineId" : null, "matchedTransitions" : [ {