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 59f160b..60e8422 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 @@ -62,16 +62,13 @@ public class StrictFqnMatchingEngine implements EventMatchingEngine { } if (polyEvents.isEmpty() && isDynamicVariable(rawTriggerEvent)) { - if (triggerPoint.getEventTypeFqn() != null) { - if (smEventRaw.startsWith(triggerPoint.getEventTypeFqn() + ".")) { - return true; - } - if (isStringTypeOrPrimitive(triggerPoint.getEventTypeFqn()) && !smEventRaw.contains(".")) { - return true; - } + if (triggerPoint.getEventTypeFqn() != null && !isStringTypeOrPrimitive(triggerPoint.getEventTypeFqn())) { return false; } - return true; + if (triggerPoint.getEventTypeFqn() != null && isStringTypeOrPrimitive(triggerPoint.getEventTypeFqn())) { + return !smEventRaw.contains("."); + } + return !smEventRaw.contains("."); } return matchEventNames(rawTriggerEvent, smEventRaw, triggerPoint.getEventTypeFqn()); 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 4fdd503..a45c837 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 @@ -6,12 +6,15 @@ import org.eclipse.jdt.core.dom.BooleanLiteral; import org.eclipse.jdt.core.dom.CompilationUnit; import org.eclipse.jdt.core.dom.EnumConstantDeclaration; import org.eclipse.jdt.core.dom.EnumDeclaration; +import org.eclipse.jdt.core.dom.AbstractTypeDeclaration; import org.eclipse.jdt.core.dom.Expression; import org.eclipse.jdt.core.dom.FieldAccess; import org.eclipse.jdt.core.dom.FieldDeclaration; import org.eclipse.jdt.core.dom.MethodDeclaration; +import org.eclipse.jdt.core.dom.MethodInvocation; import org.eclipse.jdt.core.dom.ReturnStatement; import org.eclipse.jdt.core.dom.SimpleName; +import org.eclipse.jdt.core.dom.SimpleType; import org.eclipse.jdt.core.dom.SingleVariableDeclaration; import org.eclipse.jdt.core.dom.ThisExpression; import org.eclipse.jdt.core.dom.TypeDeclaration; @@ -76,7 +79,7 @@ public final class EnumMemberPredicateEvaluator { } List filtered = new ArrayList<>(); for (String candidate : candidates) { - if (satisfiesPredicates(candidate, predicates, enumDecl)) { + if (satisfiesPredicates(candidate, predicates, enumDecl, context)) { filtered.add(candidate); } } @@ -86,16 +89,17 @@ public final class EnumMemberPredicateEvaluator { private static boolean satisfiesPredicates( String canonicalConstantFqn, List predicates, - EnumDeclaration enumDecl) { + EnumDeclaration enumDecl, + CodebaseContext context) { String constantName = constantSimpleName(canonicalConstantFqn); if (constantName == null) { return false; } Map boolFields = resolveConstantBooleanFields(enumDecl, constantName); for (PredicateCall predicate : predicates) { - Boolean methodResult = evaluateBooleanMethod(enumDecl, predicate.methodName(), boolFields); + Boolean methodResult = evaluateBooleanMethod(enumDecl, predicate.methodName(), boolFields, context); if (methodResult == null) { - return true; + return false; } boolean expected = predicate.negated ? !methodResult : methodResult; if (!expected) { @@ -108,8 +112,12 @@ public final class EnumMemberPredicateEvaluator { static Boolean evaluateBooleanMethod( EnumDeclaration enumDecl, String methodName, - Map boolFields) { + Map boolFields, + CodebaseContext context) { MethodDeclaration method = findParameterlessMethod(enumDecl, methodName); + if (method == null && context != null) { + method = findInterfaceParameterlessMethod(enumDecl, methodName, context); + } if (method == null || method.getBody() == null) { return null; } @@ -118,7 +126,7 @@ public final class EnumMemberPredicateEvaluator { @Override public boolean visit(ReturnStatement node) { if (result[0] == null) { - result[0] = evaluateBooleanExpression(node.getExpression(), boolFields); + result[0] = evaluateBooleanExpression(node.getExpression(), boolFields, enumDecl, context); } return super.visit(node); } @@ -179,7 +187,11 @@ public final class EnumMemberPredicateEvaluator { } } - private static Boolean evaluateBooleanExpression(Expression expr, Map boolFields) { + private static Boolean evaluateBooleanExpression( + Expression expr, + Map boolFields, + EnumDeclaration enumDecl, + CodebaseContext context) { if (expr instanceof BooleanLiteral bl) { return bl.booleanValue(); } @@ -191,6 +203,12 @@ public final class EnumMemberPredicateEvaluator { return boolFields.get(fa.getName().getIdentifier()); } } + if (expr instanceof MethodInvocation mi + && mi.arguments().isEmpty() + && enumDecl != null + && context != null) { + return evaluateBooleanMethod(enumDecl, mi.getName().getIdentifier(), boolFields, context); + } return null; } @@ -198,8 +216,11 @@ public final class EnumMemberPredicateEvaluator { return expr instanceof BooleanLiteral bl ? bl.booleanValue() : null; } - private static MethodDeclaration findParameterlessMethod(EnumDeclaration enumDecl, String methodName) { - for (Object bodyObj : enumDecl.bodyDeclarations()) { + private static MethodDeclaration findParameterlessMethod(AbstractTypeDeclaration typeDecl, String methodName) { + if (typeDecl == null) { + return null; + } + for (Object bodyObj : typeDecl.bodyDeclarations()) { if (bodyObj instanceof MethodDeclaration md && !md.isConstructor() && methodName.equals(md.getName().getIdentifier()) @@ -210,6 +231,25 @@ public final class EnumMemberPredicateEvaluator { return null; } + private static MethodDeclaration findInterfaceParameterlessMethod( + EnumDeclaration enumDecl, + String methodName, + CodebaseContext context) { + CompilationUnit cu = enumDecl.getRoot() instanceof CompilationUnit root ? root : null; + for (Object typeObj : enumDecl.superInterfaceTypes()) { + if (!(typeObj instanceof SimpleType simpleType)) { + continue; + } + String interfaceName = simpleType.getName().getFullyQualifiedName(); + TypeDeclaration interfaceDecl = context.getTypeDeclaration(interfaceName, cu); + MethodDeclaration method = findParameterlessMethod(interfaceDecl, methodName); + if (method != null) { + return method; + } + } + return null; + } + private static MethodDeclaration findEnumConstructor(EnumDeclaration enumDecl) { MethodDeclaration implicit = null; for (Object bodyObj : enumDecl.bodyDeclarations()) { 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 67df352..d4ba836 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 @@ -179,7 +179,7 @@ public final class MachineEnumCanonicalizer { List machineTransitions, boolean skipCanonicalization) { TriggerPoint expanded = skipCanonicalization - ? trigger + ? expandSymbolicOnly(trigger, machineTypes, context) : canonicalizeAndExpandTriggerPoint(trigger, machineTypes, context); if (expanded == null || machineTypes == null || machineTypes.eventTypeFqn() == null) { return expanded; @@ -199,21 +199,77 @@ public final class MachineEnumCanonicalizer { } return expanded; } - if (classifyTriggerEvent(expanded.getEvent()) != TriggerEventKind.DYNAMIC_EXPRESSION) { - return expanded; + if (shouldInferPolymorphicEvents(expanded, machineTransitions)) { + List machineEvents = inferPolymorphicCandidates( + expanded.getConstraint(), + machineTypes.eventTypeFqn(), + machineTransitions, + context); + if (!machineEvents.isEmpty()) { + return expanded.toBuilder() + .polymorphicEvents(machineEvents) + .ambiguous(machineEvents.size() > 1) + .build(); + } } - List machineEvents = inferPolymorphicCandidates( - expanded.getConstraint(), - machineTypes.eventTypeFqn(), - machineTransitions, - context); - if (machineEvents.isEmpty()) { - return expanded; + return expanded; + } + + private static TriggerPoint expandSymbolicOnly( + TriggerPoint trigger, + StateMachineTypeResolver.MachineTypes machineTypes, + CodebaseContext context) { + if (trigger == null || machineTypes == null || machineTypes.eventTypeFqn() == null) { + return trigger; } - return expanded.toBuilder() - .polymorphicEvents(machineEvents) - .ambiguous(machineEvents.size() > 1) - .build(); + List expanded = expandSymbolicPolymorphicEvents( + trigger.getPolymorphicEvents(), machineTypes.eventTypeFqn(), context); + if (java.util.Objects.equals(expanded, trigger.getPolymorphicEvents())) { + return trigger; + } + return trigger.toBuilder().polymorphicEvents(expanded).build(); + } + + private static boolean shouldInferPolymorphicEvents( + TriggerPoint trigger, + List machineTransitions) { + if (trigger == null || machineTransitions == null || machineTransitions.isEmpty()) { + return false; + } + if (hasOnlySymbolicPolymorphicEvents(trigger.getPolymorphicEvents())) { + return true; + } + List polyEvents = trigger.getPolymorphicEvents(); + if (polyEvents != null && !polyEvents.isEmpty()) { + return false; + } + if (EnumMemberPredicateEvaluator.hasEnumMemberPredicates(trigger.getConstraint())) { + return true; + } + if (trigger.isExternal() || trigger.isAmbiguous()) { + return true; + } + return classifyTriggerEvent(trigger.getEvent()) == TriggerEventKind.DYNAMIC_EXPRESSION; + } + + private static boolean hasOnlySymbolicPolymorphicEvents(List polymorphicEvents) { + if (polymorphicEvents == null || polymorphicEvents.isEmpty()) { + return false; + } + boolean hasSymbolic = false; + for (String pe : polymorphicEvents) { + if (pe == null) { + continue; + } + if (pe.startsWith(" inferPolymorphicCandidates( @@ -233,6 +289,9 @@ public final class MachineEnumCanonicalizer { if (!filtered.isEmpty()) { return filtered; } + if (!transitionEvents.isEmpty()) { + return transitionEvents; + } return List.of(); } @@ -242,9 +301,12 @@ public final class MachineEnumCanonicalizer { String eventTypeFqn, List machineTransitions, CodebaseContext context) { + List transitionEvents = polymorphicEventsFromTransitions(machineTransitions, eventTypeFqn); List result = EnumMemberPredicateEvaluator.filterEnumConstants( current, constraint, eventTypeFqn, context); - List transitionEvents = polymorphicEventsFromTransitions(machineTransitions, eventTypeFqn); + if (result.isEmpty() && !transitionEvents.isEmpty()) { + result = new ArrayList<>(transitionEvents); + } if (!looksOverBroad(result, transitionEvents, constraint)) { return result; } @@ -281,10 +343,18 @@ public final class MachineEnumCanonicalizer { if (transitionEvents.isEmpty()) { return current.size() > 1; } - return current.size() > transitionEvents.size(); + if (current.size() > transitionEvents.size()) { + return true; + } + for (String event : current) { + if (!transitionEvents.contains(event)) { + return true; + } + } + return false; } - static List polymorphicEventsFromTransitions( + public static List polymorphicEventsFromTransitions( List machineTransitions, String eventTypeFqn) { if (machineTransitions == null || machineTransitions.isEmpty() || eventTypeFqn == null) { diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/validation/AnalysisCanonicalFormValidator.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/validation/AnalysisCanonicalFormValidator.java index 82f8133..e790188 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/validation/AnalysisCanonicalFormValidator.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/validation/AnalysisCanonicalFormValidator.java @@ -266,6 +266,8 @@ public final class AnalysisCanonicalFormValidator { } validateMatchedTransitionsWhenResolvable( prefix, chain, trigger, transitions, machineTypes.eventTypeFqn(), violations); + validateOverLinkedPolymorphicEvents( + prefix, chain, trigger, transitions, machineTypes.eventTypeFqn(), violations); } if (chain.getMatchedTransitions() != null) { for (int j = 0; j < chain.getMatchedTransitions().size(); j++) { @@ -339,6 +341,74 @@ public final class AnalysisCanonicalFormValidator { } } + private static void validateOverLinkedPolymorphicEvents( + String chainPrefix, + CallChain chain, + TriggerPoint trigger, + List transitions, + String eventTypeFqn, + List violations) { + if (trigger.getPolymorphicEvents() == null || trigger.getPolymorphicEvents().isEmpty()) { + return; + } + if (!MachineEnumCanonicalizer.hasOnlyConcretePolymorphicEvents(trigger.getPolymorphicEvents())) { + return; + } + List transitionEvents = + MachineEnumCanonicalizer.polymorphicEventsFromTransitions(transitions, eventTypeFqn); + if (transitionEvents.isEmpty()) { + return; + } + List extras = new ArrayList<>(); + for (String polyEvent : trigger.getPolymorphicEvents()) { + if (!transitionEvents.contains(polyEvent)) { + extras.add(polyEvent); + } + } + if (!extras.isEmpty()) { + violations.add(new Violation( + chainPrefix + ".triggerPoint.polymorphicEvents", + String.valueOf(trigger.getPolymorphicEvents().size()), + "subset of configured transition events; unexpected: " + extras)); + return; + } + int matchingTransitionCount = countMatchingConfiguredTransitions( + transitions, trigger.getPolymorphicEvents(), eventTypeFqn); + if (matchingTransitionCount > 0 + && chain.getMatchedTransitions() != null + && chain.getMatchedTransitions().size() > matchingTransitionCount) { + violations.add(new Violation( + chainPrefix + ".matchedTransitions", + String.valueOf(chain.getMatchedTransitions().size()), + "at most " + matchingTransitionCount + " for configured transitions")); + } + } + + private static int countMatchingConfiguredTransitions( + List transitions, + List polymorphicEvents, + String eventTypeFqn) { + if (transitions == null || transitions.isEmpty() || polymorphicEvents == null) { + return 0; + } + int count = 0; + for (Transition transition : transitions) { + if (transition.getEvent() == null) { + continue; + } + String smEvent = transition.getEvent().fullIdentifier() != null + ? transition.getEvent().fullIdentifier() + : transition.getEvent().rawName(); + for (String polyEvent : polymorphicEvents) { + if (eventsMatch(polyEvent, smEvent, eventTypeFqn)) { + count++; + break; + } + } + } + return count; + } + private static void validateMatchedTransitionsWhenResolvable( String chainPrefix, CallChain chain, 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 7396ea4..4b4e21c 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 @@ -101,11 +101,22 @@ class StrictFqnMatchingEngineTest { } @Test - void shouldMatchWildcards() { + void shouldNotMatchWildcardAgainstEnumTransitionWithoutPolymorphicEvents() { Event smEvent = Event.of("PAY", "com.example.OrderEvents.PAY"); TriggerPoint triggerPoint = TriggerPoint.builder().event("event").build(); - assertThat(engine.matches(smEvent, triggerPoint)).isTrue(); + assertThat(engine.matches(smEvent, triggerPoint)).isFalse(); + } + + @Test + void shouldNotMatchDynamicMethodCallWithoutResolvedPolymorphicEvents() { + Event smEvent = Event.of("PAY", "com.example.OrderEvents.PAY"); + TriggerPoint triggerPoint = TriggerPoint.builder() + .event("event.getPayload()") + .eventTypeFqn("com.example.OrderEvents") + .build(); + + assertThat(engine.matches(smEvent, triggerPoint)).isFalse(); } @Test @@ -187,13 +198,25 @@ class StrictFqnMatchingEngineTest { } @Test - void shouldMatchDynamicVariableWithMatchingTypeFqn() { + void shouldNotMatchDynamicVariableWithoutResolvedPolymorphicEvents() { Event smEvent = Event.of("PAY", "com.example.OrderEvents.PAY"); TriggerPoint triggerPoint = TriggerPoint.builder() - .event("myCustomEvt") // Not "event", "e", etc., but a custom variable name + .event("myCustomEvt") .eventTypeFqn("com.example.OrderEvents") .build(); + assertThat(engine.matches(smEvent, triggerPoint)).isFalse(); + } + + @Test + void shouldMatchDynamicVariableWhenPolymorphicEventsAreResolved() { + Event smEvent = Event.of("PAY", "com.example.OrderEvents.PAY"); + TriggerPoint triggerPoint = TriggerPoint.builder() + .event("myCustomEvt") + .eventTypeFqn("com.example.OrderEvents") + .polymorphicEvents(List.of("com.example.OrderEvents.PAY")) + .build(); + assertThat(engine.matches(smEvent, triggerPoint)).isTrue(); } @@ -209,11 +232,22 @@ class StrictFqnMatchingEngineTest { } @Test - void shouldMatchDynamicVariableWithUnknownTypeFqn() { + void shouldNotMatchDynamicVariableWithUnknownTypeFqnWhenSmEventIsEnum() { Event smEvent = Event.of("PAY", "com.example.OrderEvents.PAY"); TriggerPoint triggerPoint = TriggerPoint.builder() .event("myCustomEvt") - .eventTypeFqn(null) // Unknown type, fallback to true + .eventTypeFqn(null) + .build(); + + assertThat(engine.matches(smEvent, triggerPoint)).isFalse(); + } + + @Test + void shouldMatchDynamicVariableWithUnknownTypeFqnForStringEvents() { + Event smEvent = Event.of("PAY", "PAY"); + TriggerPoint triggerPoint = TriggerPoint.builder() + .event("myCustomEvt") + .eventTypeFqn(null) .build(); assertThat(engine.matches(smEvent, triggerPoint)).isTrue(); @@ -250,15 +284,4 @@ class StrictFqnMatchingEngineTest { assertThat(engine.matches(smEvent, triggerPoint)).isFalse(); } - - @Test - void shouldMatchDynamicMethodCallAsVariable() { - Event smEvent = Event.of("PAY", "com.example.OrderEvents.PAY"); - TriggerPoint triggerPoint = TriggerPoint.builder() - .event("event.getPayload()") // Dynamic method call - .eventTypeFqn("com.example.OrderEvents") - .build(); - - assertThat(engine.matches(smEvent, triggerPoint)).isTrue(); - } } diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/EnumMemberPredicatePolymorphicInferenceTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/EnumMemberPredicatePolymorphicInferenceTest.java index 9d48588..238260e 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/EnumMemberPredicatePolymorphicInferenceTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/EnumMemberPredicatePolymorphicInferenceTest.java @@ -214,6 +214,172 @@ class EnumMemberPredicatePolymorphicInferenceTest { assertThat(result.getMetadata().getCallChains().get(0).getMatchedTransitions()).hasSize(2); } + @Test + void shouldNarrowEqualSizePolyListWithNonTransitionConstantsWhenConstraintIsNull(@TempDir Path tempDir) throws Exception { + CodebaseContext context = scanEnumProject(tempDir, """ + package com.example.order; + public enum OrderCommand { + PAY, + SHIP, + META; + } + """); + + TriggerPoint trigger = TriggerPoint.builder() + .event("event") + .polymorphicEvents(List.of( + "com.example.order.OrderCommand.PAY", + "com.example.order.OrderCommand.SHIP", + "com.example.order.OrderCommand.META")) + .constraint(null) + .ambiguous(true) + .build(); + MachineTypes types = new MachineTypes(null, "com.example.order.OrderCommand"); + List transitions = List.of( + transition("OrderCommand.PAY", "NEW", "PAID"), + transition("OrderCommand.SHIP", "PAID", "SHIPPED")); + + TriggerPoint enriched = MachineEnumCanonicalizer.ensureCallChainPolymorphicEvents( + trigger, types, context, transitions, true); + + assertThat(enriched.getPolymorphicEvents()) + .containsExactlyInAnyOrder( + "com.example.order.OrderCommand.PAY", + "com.example.order.OrderCommand.SHIP"); + assertThat(enriched.getPolymorphicEvents()) + .doesNotContain("com.example.order.OrderCommand.META"); + } + + @Test + void shouldFallBackToTransitionEventsWhenPredicateEvaluationIsInconclusive(@TempDir Path tempDir) throws Exception { + CodebaseContext context = scanEnumProject(tempDir, """ + package com.example.order; + public enum OrderCommand { + PAY, + SHIP, + META; + } + """); + + TriggerPoint trigger = TriggerPoint.builder() + .event("OrderCommand.valueOf(name)") + .constraint("eventType.isEvent()") + .build(); + MachineTypes types = new MachineTypes(null, "com.example.order.OrderCommand"); + List transitions = List.of( + transition("OrderCommand.PAY", "NEW", "PAID"), + transition("OrderCommand.SHIP", "PAID", "SHIPPED")); + + TriggerPoint enriched = MachineEnumCanonicalizer.ensureCallChainPolymorphicEvents( + trigger, types, context, transitions, true); + + assertThat(enriched.getPolymorphicEvents()) + .containsExactlyInAnyOrder( + "com.example.order.OrderCommand.PAY", + "com.example.order.OrderCommand.SHIP"); + } + + @Test + void shouldExcludeConstantsWhenPredicateMethodCannotBeEvaluated(@TempDir Path tempDir) throws Exception { + CodebaseContext context = scanEnumProject(tempDir, """ + package com.example.order; + public enum OrderCommand { + PAY, + META; + } + """); + + List filtered = EnumMemberPredicateEvaluator.filterEnumConstants( + List.of( + "com.example.order.OrderCommand.PAY", + "com.example.order.OrderCommand.META"), + "eventType.isEvent()", + "com.example.order.OrderCommand", + context); + + assertThat(filtered).isEmpty(); + } + + @Test + void shouldEvaluateInterfaceDefaultPredicateDelegation(@TempDir Path tempDir) throws Exception { + Path javaRoot = tempDir.resolve("src/main/java/com/example"); + Files.createDirectories(javaRoot.resolve("core")); + Files.createDirectories(javaRoot.resolve("order")); + Files.writeString(tempDir.resolve("build.gradle"), "plugins { id 'java' }"); + Files.writeString(javaRoot.resolve("core/TriggerClassifier.java"), """ + package com.example.core; + public interface TriggerClassifier { + default boolean isEvent() { return isTrigger(); } + boolean isTrigger(); + } + """); + Files.writeString(javaRoot.resolve("order/OrderEvent.java"), """ + package com.example.order; + import com.example.core.TriggerClassifier; + public enum OrderEvent implements TriggerClassifier { + PAY(true), + LOG(false); + private final boolean trigger; + OrderEvent(boolean trigger) { this.trigger = trigger; } + @Override + public boolean isTrigger() { return this.trigger; } + } + """); + CodebaseContext context = new CodebaseContext(); + context.setSourcepath(List.of(tempDir.resolve("src/main/java").toString())); + context.scan(tempDir); + + List filtered = EnumMemberPredicateEvaluator.filterEnumConstants( + List.of("com.example.order.OrderEvent.PAY", "com.example.order.OrderEvent.LOG"), + "eventType.isEvent()", + "com.example.order.OrderEvent", + context); + + assertThat(filtered).containsExactly("com.example.order.OrderEvent.PAY"); + } + + @Test + void shouldInferPolymorphicEventsForNullPolyExternalTriggerWithConstraint(@TempDir Path tempDir) throws Exception { + CodebaseContext context = scanEnumProject(tempDir, """ + package com.example.order; + public enum OrderCommand { + PAY(true), + SHIP(true), + META(false); + private final boolean event; + OrderCommand(boolean event) { this.event = event; } + public boolean isEvent() { return event; } + } + """); + + CallChain chain = CallChain.builder() + .triggerPoint(TriggerPoint.builder() + .event("eventType") + .constraint("eventType.isEvent()") + .external(true) + .ambiguous(true) + .build()) + .build(); + + Transition pay = transition("OrderCommand.PAY", "NEW", "PAID"); + Transition ship = transition("OrderCommand.SHIP", "PAID", "SHIPPED"); + AnalysisResult result = AnalysisResult.builder() + .name("com.example.config.OrderStateMachineConfiguration") + .eventTypeFqn("com.example.order.OrderCommand") + .transitions(List.of(pay, ship)) + .metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build()) + .build(); + + new TransitionLinkerEnricher().enrich(result, context, null); + + TriggerPoint tp = result.getMetadata().getCallChains().get(0).getTriggerPoint(); + assertThat(tp.getPolymorphicEvents()) + .containsExactlyInAnyOrder( + "com.example.order.OrderCommand.PAY", + "com.example.order.OrderCommand.SHIP"); + assertThat(result.getMetadata().getCallChains().get(0).getMatchedTransitions()).hasSize(2); + } + @Test void shouldNotMatchNonEventConstantsWhenPolyListWasOverBroad(@TempDir Path tempDir) throws Exception { CodebaseContext context = scanEnumProject(tempDir, """