From 77bf8404656adc4248d1adf903a4cc36e4250e58 Mon Sep 17 00:00:00 2001 From: Kamil Patryk Kozakowski Date: Sun, 12 Jul 2026 16:14:44 +0200 Subject: [PATCH] Filter polymorphic events by enum predicates and machine transitions. Add generic constraint-aware enum member evaluation, prefer transitions[] over full enum inference, and regression tests for boolean enum dispatchers. Co-authored-by: Cursor --- .../EnumMemberPredicateEvaluator.java | 305 ++++++++++++++++++ .../resolver/MachineEnumCanonicalizer.java | 94 +++++- ...erpriseBooleanEnumPredicateExportTest.java | 190 +++++++++++ ...mberPredicatePolymorphicInferenceTest.java | 278 ++++++++++++++++ .../UserStateMachineConfiguration.json | 4 +- 5 files changed, 864 insertions(+), 7 deletions(-) create mode 100644 state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/EnumMemberPredicateEvaluator.java create mode 100644 state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/EnterpriseBooleanEnumPredicateExportTest.java create mode 100644 state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/EnumMemberPredicatePolymorphicInferenceTest.java 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 new file mode 100644 index 0000000..4fdd503 --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/EnumMemberPredicateEvaluator.java @@ -0,0 +1,305 @@ +package click.kamil.springstatemachineexporter.analysis.resolver; + +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import org.eclipse.jdt.core.dom.ASTVisitor; +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.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.ReturnStatement; +import org.eclipse.jdt.core.dom.SimpleName; +import org.eclipse.jdt.core.dom.SingleVariableDeclaration; +import org.eclipse.jdt.core.dom.ThisExpression; +import org.eclipse.jdt.core.dom.TypeDeclaration; +import org.eclipse.jdt.core.dom.VariableDeclarationFragment; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Evaluates no-arg boolean member calls in dispatcher constraints (e.g. {@code eventType.isEvent()}) + * against concrete enum constants by reading enum source — no hard-coded predicate names. + */ +public final class EnumMemberPredicateEvaluator { + + private static final Pattern PREDICATE_PART = + Pattern.compile("!?\\s*([a-zA-Z][\\w]*)\\s*\\.\\s*([a-zA-Z][\\w]*)\\s*\\(\\s*\\)"); + + private EnumMemberPredicateEvaluator() { + } + + public record PredicateCall(String receiverName, String methodName, boolean negated) { + } + + public static boolean hasEnumMemberPredicates(String constraint) { + return !extractPredicateCalls(constraint).isEmpty(); + } + + public static List extractPredicateCalls(String constraint) { + if (constraint == null || constraint.isBlank()) { + return List.of(); + } + List calls = new ArrayList<>(); + for (String part : splitTopLevelAndParts(constraint)) { + Matcher matcher = PREDICATE_PART.matcher(part.trim()); + if (matcher.matches()) { + boolean negated = part.trim().startsWith("!"); + calls.add(new PredicateCall(matcher.group(1), matcher.group(2), negated)); + } + } + return calls; + } + + public static List filterEnumConstants( + List candidates, + String constraint, + String enumTypeFqn, + CodebaseContext context) { + if (candidates == null || candidates.isEmpty()) { + return candidates == null ? List.of() : candidates; + } + List predicates = extractPredicateCalls(constraint); + if (predicates.isEmpty()) { + return candidates; + } + EnumDeclaration enumDecl = findEnumDeclaration(enumTypeFqn, context); + if (enumDecl == null) { + return List.of(); + } + List filtered = new ArrayList<>(); + for (String candidate : candidates) { + if (satisfiesPredicates(candidate, predicates, enumDecl)) { + filtered.add(candidate); + } + } + return filtered; + } + + private static boolean satisfiesPredicates( + String canonicalConstantFqn, + List predicates, + EnumDeclaration enumDecl) { + 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); + if (methodResult == null) { + return true; + } + boolean expected = predicate.negated ? !methodResult : methodResult; + if (!expected) { + return false; + } + } + return true; + } + + static Boolean evaluateBooleanMethod( + EnumDeclaration enumDecl, + String methodName, + Map boolFields) { + MethodDeclaration method = findParameterlessMethod(enumDecl, methodName); + if (method == null || method.getBody() == null) { + return null; + } + Boolean[] result = new Boolean[1]; + method.getBody().accept(new ASTVisitor() { + @Override + public boolean visit(ReturnStatement node) { + if (result[0] == null) { + result[0] = evaluateBooleanExpression(node.getExpression(), boolFields); + } + return super.visit(node); + } + }); + return result[0]; + } + + static Map resolveConstantBooleanFields(EnumDeclaration enumDecl, String constantName) { + Map fields = new HashMap<>(); + EnumConstantDeclaration constant = findEnumConstant(enumDecl, constantName); + if (constant == null) { + return fields; + } + MethodDeclaration constructor = findEnumConstructor(enumDecl); + if (constructor != null && constant.arguments().size() == constructor.parameters().size()) { + for (int i = 0; i < constructor.parameters().size(); i++) { + SingleVariableDeclaration param = + (SingleVariableDeclaration) constructor.parameters().get(i); + Expression arg = (Expression) constant.arguments().get(i); + Boolean boolVal = literalBoolean(arg); + if (boolVal != null) { + fields.put(param.getName().getIdentifier(), boolVal); + mapFieldName(enumDecl, param.getName().getIdentifier(), boolVal, fields); + } + } + } + for (Object bodyObj : enumDecl.bodyDeclarations()) { + if (bodyObj instanceof FieldDeclaration fd) { + for (Object fragObj : fd.fragments()) { + if (fragObj instanceof VariableDeclarationFragment fragment) { + Expression init = fragment.getInitializer(); + Boolean boolVal = literalBoolean(init); + if (boolVal != null) { + fields.put(fragment.getName().getIdentifier(), boolVal); + } + } + } + } + } + return fields; + } + + private static void mapFieldName( + EnumDeclaration enumDecl, + String paramName, + Boolean value, + Map fields) { + for (Object bodyObj : enumDecl.bodyDeclarations()) { + if (!(bodyObj instanceof FieldDeclaration fd)) { + continue; + } + for (Object fragObj : fd.fragments()) { + if (fragObj instanceof VariableDeclarationFragment fragment + && fragment.getName().getIdentifier().equals(paramName)) { + fields.put(paramName, value); + } + } + } + } + + private static Boolean evaluateBooleanExpression(Expression expr, Map boolFields) { + if (expr instanceof BooleanLiteral bl) { + return bl.booleanValue(); + } + if (expr instanceof SimpleName sn) { + return boolFields.get(sn.getIdentifier()); + } + if (expr instanceof FieldAccess fa) { + if (fa.getExpression() == null || fa.getExpression() instanceof ThisExpression) { + return boolFields.get(fa.getName().getIdentifier()); + } + } + return null; + } + + private static Boolean literalBoolean(Expression expr) { + return expr instanceof BooleanLiteral bl ? bl.booleanValue() : null; + } + + private static MethodDeclaration findParameterlessMethod(EnumDeclaration enumDecl, String methodName) { + for (Object bodyObj : enumDecl.bodyDeclarations()) { + if (bodyObj instanceof MethodDeclaration md + && !md.isConstructor() + && methodName.equals(md.getName().getIdentifier()) + && md.parameters().isEmpty()) { + return md; + } + } + return null; + } + + private static MethodDeclaration findEnumConstructor(EnumDeclaration enumDecl) { + MethodDeclaration implicit = null; + for (Object bodyObj : enumDecl.bodyDeclarations()) { + if (bodyObj instanceof MethodDeclaration md && md.isConstructor()) { + if (md.parameters().isEmpty()) { + implicit = md; + } else { + return md; + } + } + } + return implicit; + } + + private static EnumConstantDeclaration findEnumConstant(EnumDeclaration enumDecl, String constantName) { + for (Object constantObj : enumDecl.enumConstants()) { + if (constantObj instanceof EnumConstantDeclaration ecd + && constantName.equals(ecd.getName().getIdentifier())) { + return ecd; + } + } + return null; + } + + static EnumDeclaration findEnumDeclaration(String enumTypeFqn, CodebaseContext context) { + if (enumTypeFqn == null || context == null) { + return null; + } + String clean = enumTypeFqn.contains("<") ? enumTypeFqn.substring(0, enumTypeFqn.indexOf('<')) : enumTypeFqn; + for (CompilationUnit cu : context.getCompilationUnits()) { + String pkg = cu.getPackage() != null ? cu.getPackage().getName().getFullyQualifiedName() : ""; + for (Object typeObj : cu.types()) { + EnumDeclaration found = findEnumNode(typeObj, clean, pkg, context); + if (found != null) { + return found; + } + } + } + return null; + } + + private static EnumDeclaration findEnumNode(Object typeObj, String targetFqn, String pkg, CodebaseContext context) { + if (typeObj instanceof EnumDeclaration ed) { + String fqn = pkg.isEmpty() ? ed.getName().getIdentifier() : pkg + "." + ed.getName().getIdentifier(); + if (targetFqn.equals(fqn)) { + return ed; + } + } else if (typeObj instanceof TypeDeclaration td) { + String parentFqn = pkg.isEmpty() ? td.getName().getIdentifier() : pkg + "." + td.getName().getIdentifier(); + for (Object decl : td.bodyDeclarations()) { + if (decl instanceof EnumDeclaration nested) { + String nestedFqn = parentFqn + "." + nested.getName().getIdentifier(); + if (targetFqn.equals(nestedFqn)) { + return nested; + } + } + } + } + return null; + } + + private static String constantSimpleName(String canonicalConstantFqn) { + if (canonicalConstantFqn == null || !canonicalConstantFqn.contains(".")) { + return canonicalConstantFqn; + } + return canonicalConstantFqn.substring(canonicalConstantFqn.lastIndexOf('.') + 1); + } + + private static List splitTopLevelAndParts(String constraint) { + List parts = new ArrayList<>(); + StringBuilder current = new StringBuilder(); + int depth = 0; + for (int i = 0; i < constraint.length(); i++) { + char c = constraint.charAt(i); + if (c == '(') { + depth++; + } else if (c == ')') { + depth--; + } else if (depth == 0 && i + 1 < constraint.length() + && c == '&' && constraint.charAt(i + 1) == '&') { + parts.add(current.toString()); + current.setLength(0); + i++; + continue; + } + current.append(c); + } + if (!current.isEmpty()) { + parts.add(current.toString()); + } + return parts; + } +} 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 cee0c94..67df352 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 @@ -185,19 +185,103 @@ public final class MachineEnumCanonicalizer { return expanded; } if (hasConcretePolymorphicEvents(expanded.getPolymorphicEvents())) { + List narrowed = narrowPolymorphicCandidates( + expanded.getPolymorphicEvents(), + expanded.getConstraint(), + machineTypes.eventTypeFqn(), + machineTransitions, + context); + if (!narrowed.equals(expanded.getPolymorphicEvents())) { + return expanded.toBuilder() + .polymorphicEvents(narrowed) + .ambiguous(narrowed.size() > 1) + .build(); + } return expanded; } if (classifyTriggerEvent(expanded.getEvent()) != TriggerEventKind.DYNAMIC_EXPRESSION) { return expanded; } - List machineEvents = allPackageCanonicalEnumConstants(machineTypes.eventTypeFqn(), context); - if (machineEvents.isEmpty()) { - machineEvents = polymorphicEventsFromTransitions(machineTransitions, machineTypes.eventTypeFqn()); - } + List machineEvents = inferPolymorphicCandidates( + expanded.getConstraint(), + machineTypes.eventTypeFqn(), + machineTransitions, + context); if (machineEvents.isEmpty()) { return expanded; } - return expanded.toBuilder().polymorphicEvents(machineEvents).ambiguous(true).build(); + return expanded.toBuilder() + .polymorphicEvents(machineEvents) + .ambiguous(machineEvents.size() > 1) + .build(); + } + + private static List inferPolymorphicCandidates( + String constraint, + String eventTypeFqn, + 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; + } + List enumConstants = allPackageCanonicalEnumConstants(eventTypeFqn, context); + List filtered = EnumMemberPredicateEvaluator.filterEnumConstants( + enumConstants, constraint, eventTypeFqn, context); + if (!filtered.isEmpty()) { + return filtered; + } + return List.of(); + } + + private static List narrowPolymorphicCandidates( + List current, + String constraint, + String eventTypeFqn, + List machineTransitions, + CodebaseContext context) { + List result = EnumMemberPredicateEvaluator.filterEnumConstants( + current, constraint, eventTypeFqn, context); + List transitionEvents = polymorphicEventsFromTransitions(machineTransitions, eventTypeFqn); + 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); + } + } + if (!intersected.isEmpty()) { + return intersected; + } + List transitionFiltered = EnumMemberPredicateEvaluator.filterEnumConstants( + transitionEvents, constraint, eventTypeFqn, context); + return transitionFiltered.isEmpty() ? transitionEvents : transitionFiltered; + } + return result; + } + + private static boolean looksOverBroad( + List current, + List transitionEvents, + String constraint) { + if (current == null || current.isEmpty()) { + return false; + } + if (EnumMemberPredicateEvaluator.hasEnumMemberPredicates(constraint)) { + return true; + } + if (current.size() <= 1) { + return false; + } + if (transitionEvents.isEmpty()) { + return current.size() > 1; + } + return current.size() > transitionEvents.size(); } static List polymorphicEventsFromTransitions( diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/EnterpriseBooleanEnumPredicateExportTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/EnterpriseBooleanEnumPredicateExportTest.java new file mode 100644 index 0000000..8d6f145 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/EnterpriseBooleanEnumPredicateExportTest.java @@ -0,0 +1,190 @@ +package click.kamil.springstatemachineexporter.analysis.resolver; + +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.Set; +import java.util.stream.Collectors; +import java.util.stream.StreamSupport; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * End-to-end regression mirroring enterprise enums: boolean ctor arg + {@code isEvent()} guard + * on dynamic dispatch must not link every enum constant to every transition. + */ +class EnterpriseBooleanEnumPredicateExportTest { + + @Test + void exportShouldNotLinkAllTransitionsWhenEnumHasNonEventConstants(@TempDir Path tempDir) throws Exception { + writeEnterpriseStyleProject(tempDir); + + Path outputDir = tempDir.resolve("out"); + ExportService exportService = new ExportService(List.of(new JsonExporter())); + exportService.runExporter( + tempDir, + outputDir, + List.of("json"), + true, + List.of(), + null, + "OrderStateMachineConfiguration", + click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, + click.kamil.springstatemachineexporter.exporter.EnumFormat.fn); + + Path jsonFile = outputDir.resolve( + "com.example.config.OrderStateMachineConfiguration/com.example.config.OrderStateMachineConfiguration.json"); + assertThat(jsonFile).exists(); + + JsonNode root = new ObjectMapper().readTree(jsonFile.toFile()); + int transitionCount = root.get("transitions").size(); + assertThat(transitionCount).isEqualTo(2); + + List callChains = StreamSupport.stream(root.get("metadata").get("callChains").spliterator(), false) + .toList(); + assertThat(callChains).isNotEmpty(); + + JsonNode dynamicChain = callChains.stream() + .filter(chain -> chain.get("methodChain").toString().contains("OrderDispatcher.dispatch")) + .findFirst() + .orElseThrow(() -> new AssertionError("missing OrderDispatcher.dispatch chain: " + callChains)); + + JsonNode trigger = dynamicChain.get("triggerPoint"); + List polyEvents = StreamSupport.stream(trigger.get("polymorphicEvents").spliterator(), false) + .map(JsonNode::asText) + .toList(); + + assertThat(polyEvents) + .as("polymorphicEvents must exclude non-event enum constants") + .doesNotContain( + "com.example.order.OrderEvent.LOG", + "com.example.order.OrderEvent.META") + .contains( + "com.example.order.OrderEvent.PAY", + "com.example.order.OrderEvent.SHIP"); + assertThat(polyEvents.size()).isLessThan(4); + + List matched = StreamSupport.stream(dynamicChain.get("matchedTransitions").spliterator(), false) + .toList(); + assertThat(matched.size()) + .as("matchedTransitions must not cover every transition × every enum constant") + .isLessThanOrEqualTo(transitionCount); + Set matchedEvents = matched.stream() + .map(node -> node.get("event").asText()) + .collect(Collectors.toSet()); + assertThat(matchedEvents) + .doesNotContain("com.example.order.OrderEvent.LOG", "com.example.order.OrderEvent.META"); + } + + private static void writeEnterpriseStyleProject(Path projectRoot) throws Exception { + Path javaRoot = projectRoot.resolve("src/main/java"); + Files.createDirectories(javaRoot.resolve("com/example/core")); + Files.createDirectories(javaRoot.resolve("com/example/order")); + Files.createDirectories(javaRoot.resolve("com/example/config")); + Files.createDirectories(javaRoot.resolve("com/example/web")); + Files.writeString(projectRoot.resolve("build.gradle"), "plugins { id 'java' }"); + + Files.writeString(javaRoot.resolve("com/example/core/TriggerClassifier.java"), """ + package com.example.core; + public interface TriggerClassifier { + boolean isEvent(); + } + """); + + Files.writeString(javaRoot.resolve("com/example/order/OrderState.java"), """ + package com.example.order; + public enum OrderState { NEW, PENDING, SHIPPED } + """); + + Files.writeString(javaRoot.resolve("com/example/order/OrderEvent.java"), """ + package com.example.order; + import com.example.core.TriggerClassifier; + public enum OrderEvent implements TriggerClassifier { + PAY(true), + SHIP(true), + LOG(false), + META(false); + private final boolean event; + OrderEvent(boolean event) { this.event = event; } + @Override + public boolean isEvent() { return this.event; } + } + """); + + Files.writeString(javaRoot.resolve("com/example/config/OrderStateMachineConfiguration.java"), """ + package com.example.config; + import com.example.order.OrderEvent; + import com.example.order.OrderState; + import org.springframework.context.annotation.Configuration; + import org.springframework.statemachine.config.EnableStateMachineFactory; + import org.springframework.statemachine.config.EnumStateMachineConfigurerAdapter; + @Configuration + @EnableStateMachineFactory(name = "orderStateMachineFactory") + public class OrderStateMachineConfiguration + extends EnumStateMachineConfigurerAdapter { + @Override + public void configure( + org.springframework.statemachine.config.builders.StateMachineStateConfigurer states) + throws Exception { + states.withStates().initial(OrderState.NEW).state(OrderState.PENDING).end(OrderState.SHIPPED); + } + @Override + public void configure( + org.springframework.statemachine.config.builders.StateMachineTransitionConfigurer transitions) + throws Exception { + transitions.withExternal().source(OrderState.NEW).target(OrderState.PENDING).event(OrderEvent.PAY) + .and().withExternal().source(OrderState.PENDING).target(OrderState.SHIPPED).event(OrderEvent.SHIP); + } + } + """); + + Files.writeString(javaRoot.resolve("com/example/web/OrderDispatcher.java"), """ + package com.example.web; + import com.example.order.OrderEvent; + import com.example.order.OrderState; + 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 OrderDispatcher { + @Autowired + private StateMachineFactory orderStateMachineFactory; + public String dispatch(String eventString) { + OrderEvent eventType = OrderEvent.valueOf(eventString.toUpperCase()); + if (eventType.isEvent()) { + StateMachine machine = orderStateMachineFactory.getStateMachine(); + machine.start(); + machine.sendEvent(eventType); + return "Triggered " + eventType; + } + return "Ignored " + eventType; + } + } + """); + + Files.writeString(javaRoot.resolve("com/example/web/OrderController.java"), """ + package com.example.web; + import org.springframework.beans.factory.annotation.Autowired; + import org.springframework.web.bind.annotation.PathVariable; + import org.springframework.web.bind.annotation.PostMapping; + import org.springframework.web.bind.annotation.RestController; + @RestController + public class OrderController { + @Autowired + private OrderDispatcher orderDispatcher; + @PostMapping("/api/order/{event}") + public void transition(@PathVariable("event") String event) { + orderDispatcher.dispatch(event); + } + } + """); + } +} 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 new file mode 100644 index 0000000..9d48588 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/EnumMemberPredicatePolymorphicInferenceTest.java @@ -0,0 +1,278 @@ +package click.kamil.springstatemachineexporter.analysis.resolver; + +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.TriggerPoint; +import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver.MachineTypes; +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 EnumMemberPredicatePolymorphicInferenceTest { + + @Test + void shouldExtractAnyBooleanMemberPredicateFromConstraint() { + assertThat(EnumMemberPredicateEvaluator.extractPredicateCalls("eventType.isEvent()")) + .containsExactly(new EnumMemberPredicateEvaluator.PredicateCall("eventType", "isEvent", false)); + assertThat(EnumMemberPredicateEvaluator.extractPredicateCalls("!cmd.isActive()")) + .containsExactly(new EnumMemberPredicateEvaluator.PredicateCall("cmd", "isActive", true)); + } + + @Test + void shouldFilterEnumConstantsByGenericBooleanMember(@TempDir Path tempDir) throws Exception { + CodebaseContext context = scanEnumProject(tempDir, """ + package com.example.order; + public enum OrderCommand { + PAY(true), + META(false); + private final boolean event; + OrderCommand(boolean event) { this.event = event; } + public boolean isEvent() { return event; } + } + """); + + List all = List.of( + "com.example.order.OrderCommand.PAY", + "com.example.order.OrderCommand.META"); + List filtered = EnumMemberPredicateEvaluator.filterEnumConstants( + all, "eventType.isEvent()", "com.example.order.OrderCommand", context); + + assertThat(filtered).containsExactly("com.example.order.OrderCommand.PAY"); + } + + @Test + void shouldPreferTransitionEventsOverFullEnumOnDynamicTrigger(@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; } + } + """); + + 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()) + .containsExactly( + "com.example.order.OrderCommand.PAY", + "com.example.order.OrderCommand.SHIP"); + assertThat(enriched.isAmbiguous()).isTrue(); + } + + @Test + void shouldNotWidenSingleConcretePolymorphicEventWhenConstraintIsNull() { + TriggerPoint trigger = TriggerPoint.builder() + .event("event") + .polymorphicEvents(List.of("com.example.order.OrderCommand.PAY")) + .constraint(null) + .ambiguous(false) + .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, null, transitions, true); + + assertThat(enriched.getPolymorphicEvents()).containsExactly("com.example.order.OrderCommand.PAY"); + assertThat(enriched.isAmbiguous()).isFalse(); + } + + @Test + void shouldFilterUsingNonIsPrefixedPredicateMethod(@TempDir Path tempDir) throws Exception { + CodebaseContext context = scanEnumProject(tempDir, """ + package com.example.order; + public enum OrderCommand { + PAY(true), + AUDIT(false); + private final boolean fires; + OrderCommand(boolean fires) { this.fires = fires; } + public boolean canFire() { return fires; } + } + """); + + List filtered = EnumMemberPredicateEvaluator.filterEnumConstants( + List.of( + "com.example.order.OrderCommand.PAY", + "com.example.order.OrderCommand.AUDIT"), + "eventType.canFire()", + "com.example.order.OrderCommand", + context); + + assertThat(filtered).containsExactly("com.example.order.OrderCommand.PAY"); + } + + @Test + void shouldFilterEnterpriseInterfaceOverrideStyle(@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 { + boolean isEvent(); + } + """); + 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 event; + OrderEvent(boolean event) { this.event = event; } + @Override + public boolean isEvent() { return this.event; } + } + """); + 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 shouldNarrowOverBroadPolymorphicEventsToTransitionSubset(@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("event") + .polymorphicEvents(List.of( + "com.example.order.OrderCommand.PAY", + "com.example.order.OrderCommand.SHIP", + "com.example.order.OrderCommand.META")) + .constraint("eventType.isEvent()") + .ambiguous(true) + .external(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(tp.getPolymorphicEvents()) + .doesNotContain("com.example.order.OrderCommand.META"); + assertThat(tp.isAmbiguous()).isTrue(); + assertThat(result.getMetadata().getCallChains().get(0).getMatchedTransitions()).hasSize(2); + } + + @Test + void shouldNotMatchNonEventConstantsWhenPolyListWasOverBroad(@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("event") + .polymorphicEvents(List.of( + "com.example.order.OrderCommand.PAY", + "com.example.order.OrderCommand.SHIP", + "com.example.order.OrderCommand.META")) + .constraint("eventType.isEvent()") + .ambiguous(true) + .external(true) + .build()) + .build(); + + Transition pay = transition("OrderCommand.PAY", "NEW", "PAID"); + AnalysisResult result = AnalysisResult.builder() + .name("com.example.config.OrderStateMachineConfiguration") + .eventTypeFqn("com.example.order.OrderCommand") + .transitions(List.of(pay)) + .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()).containsExactly("com.example.order.OrderCommand.PAY"); + assertThat(tp.isAmbiguous()).isFalse(); + assertThat(result.getMetadata().getCallChains().get(0).getMatchedTransitions()).hasSize(1); + } + + private static CodebaseContext scanEnumProject(Path tempDir, String enumSource) throws Exception { + Path javaRoot = tempDir.resolve("src/main/java/com/example/order"); + Files.createDirectories(javaRoot); + Files.writeString(tempDir.resolve("build.gradle"), "plugins { id 'java' }"); + Files.writeString(javaRoot.resolve("OrderCommand.java"), enumSource); + CodebaseContext context = new CodebaseContext(); + context.setSourcepath(List.of(tempDir.resolve("src/main/java").toString())); + context.scan(tempDir); + return context; + } + + private static Transition transition(String event, String source, String target) { + Transition transition = new Transition(); + transition.setEvent(Event.of(event, event)); + transition.setSourceStates(List.of(State.of(source, source))); + transition.setTargetStates(List.of(State.of(target, target))); + return transition; + } +} 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 2fa1605..dab184e 100644 --- a/state_machine_exporter/src/test/resources/golden/UserStateMachineConfiguration/UserStateMachineConfiguration.json +++ b/state_machine_exporter/src/test/resources/golden/UserStateMachineConfiguration/UserStateMachineConfiguration.json @@ -151,10 +151,10 @@ "stateMachineId" : null, "sourceState" : null, "lineNumber" : 93, - "polymorphicEvents" : [ "click.kamil.enterprise.machines.user.UserEvent.REGISTER", "click.kamil.enterprise.machines.user.UserEvent.VERIFY", "click.kamil.enterprise.machines.user.UserEvent.SUSPEND" ], + "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" : false + "ambiguous" : true }, "contextMachineId" : null, "matchedTransitions" : [ {