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 <cursoragent@cursor.com>
This commit is contained in:
@@ -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<PredicateCall> extractPredicateCalls(String constraint) {
|
||||
if (constraint == null || constraint.isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
List<PredicateCall> 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<String> filterEnumConstants(
|
||||
List<String> candidates,
|
||||
String constraint,
|
||||
String enumTypeFqn,
|
||||
CodebaseContext context) {
|
||||
if (candidates == null || candidates.isEmpty()) {
|
||||
return candidates == null ? List.of() : candidates;
|
||||
}
|
||||
List<PredicateCall> predicates = extractPredicateCalls(constraint);
|
||||
if (predicates.isEmpty()) {
|
||||
return candidates;
|
||||
}
|
||||
EnumDeclaration enumDecl = findEnumDeclaration(enumTypeFqn, context);
|
||||
if (enumDecl == null) {
|
||||
return List.of();
|
||||
}
|
||||
List<String> filtered = new ArrayList<>();
|
||||
for (String candidate : candidates) {
|
||||
if (satisfiesPredicates(candidate, predicates, enumDecl)) {
|
||||
filtered.add(candidate);
|
||||
}
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
|
||||
private static boolean satisfiesPredicates(
|
||||
String canonicalConstantFqn,
|
||||
List<PredicateCall> predicates,
|
||||
EnumDeclaration enumDecl) {
|
||||
String constantName = constantSimpleName(canonicalConstantFqn);
|
||||
if (constantName == null) {
|
||||
return false;
|
||||
}
|
||||
Map<String, Boolean> 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<String, Boolean> 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<String, Boolean> resolveConstantBooleanFields(EnumDeclaration enumDecl, String constantName) {
|
||||
Map<String, Boolean> 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<String, Boolean> 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<String, Boolean> 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<String> splitTopLevelAndParts(String constraint) {
|
||||
List<String> 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;
|
||||
}
|
||||
}
|
||||
@@ -185,19 +185,103 @@ public final class MachineEnumCanonicalizer {
|
||||
return expanded;
|
||||
}
|
||||
if (hasConcretePolymorphicEvents(expanded.getPolymorphicEvents())) {
|
||||
List<String> 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<String> machineEvents = allPackageCanonicalEnumConstants(machineTypes.eventTypeFqn(), context);
|
||||
if (machineEvents.isEmpty()) {
|
||||
machineEvents = polymorphicEventsFromTransitions(machineTransitions, machineTypes.eventTypeFqn());
|
||||
}
|
||||
List<String> 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<String> inferPolymorphicCandidates(
|
||||
String constraint,
|
||||
String eventTypeFqn,
|
||||
List<Transition> machineTransitions,
|
||||
CodebaseContext context) {
|
||||
List<String> transitionEvents = polymorphicEventsFromTransitions(machineTransitions, eventTypeFqn);
|
||||
if (!transitionEvents.isEmpty()) {
|
||||
List<String> filtered = EnumMemberPredicateEvaluator.filterEnumConstants(
|
||||
transitionEvents, constraint, eventTypeFqn, context);
|
||||
return filtered.isEmpty() ? transitionEvents : filtered;
|
||||
}
|
||||
List<String> enumConstants = allPackageCanonicalEnumConstants(eventTypeFqn, context);
|
||||
List<String> filtered = EnumMemberPredicateEvaluator.filterEnumConstants(
|
||||
enumConstants, constraint, eventTypeFqn, context);
|
||||
if (!filtered.isEmpty()) {
|
||||
return filtered;
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
|
||||
private static List<String> narrowPolymorphicCandidates(
|
||||
List<String> current,
|
||||
String constraint,
|
||||
String eventTypeFqn,
|
||||
List<Transition> machineTransitions,
|
||||
CodebaseContext context) {
|
||||
List<String> result = EnumMemberPredicateEvaluator.filterEnumConstants(
|
||||
current, constraint, eventTypeFqn, context);
|
||||
List<String> transitionEvents = polymorphicEventsFromTransitions(machineTransitions, eventTypeFqn);
|
||||
if (!looksOverBroad(result, transitionEvents, constraint)) {
|
||||
return result;
|
||||
}
|
||||
if (!transitionEvents.isEmpty()) {
|
||||
List<String> intersected = new ArrayList<>();
|
||||
for (String event : result) {
|
||||
if (transitionEvents.contains(event)) {
|
||||
intersected.add(event);
|
||||
}
|
||||
}
|
||||
if (!intersected.isEmpty()) {
|
||||
return intersected;
|
||||
}
|
||||
List<String> transitionFiltered = EnumMemberPredicateEvaluator.filterEnumConstants(
|
||||
transitionEvents, constraint, eventTypeFqn, context);
|
||||
return transitionFiltered.isEmpty() ? transitionEvents : transitionFiltered;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static boolean looksOverBroad(
|
||||
List<String> current,
|
||||
List<String> 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<String> polymorphicEventsFromTransitions(
|
||||
|
||||
@@ -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<JsonNode> 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<String> 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<JsonNode> 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<String> 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<OrderState, OrderEvent> {
|
||||
@Override
|
||||
public void configure(
|
||||
org.springframework.statemachine.config.builders.StateMachineStateConfigurer<OrderState, OrderEvent> states)
|
||||
throws Exception {
|
||||
states.withStates().initial(OrderState.NEW).state(OrderState.PENDING).end(OrderState.SHIPPED);
|
||||
}
|
||||
@Override
|
||||
public void configure(
|
||||
org.springframework.statemachine.config.builders.StateMachineTransitionConfigurer<OrderState, OrderEvent> 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<OrderState, OrderEvent> orderStateMachineFactory;
|
||||
public String dispatch(String eventString) {
|
||||
OrderEvent eventType = OrderEvent.valueOf(eventString.toUpperCase());
|
||||
if (eventType.isEvent()) {
|
||||
StateMachine<OrderState, OrderEvent> 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);
|
||||
}
|
||||
}
|
||||
""");
|
||||
}
|
||||
}
|
||||
@@ -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<String> all = List.of(
|
||||
"com.example.order.OrderCommand.PAY",
|
||||
"com.example.order.OrderCommand.META");
|
||||
List<String> 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<Transition> 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<Transition> 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<String> 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<String> 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;
|
||||
}
|
||||
}
|
||||
@@ -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" : [ {
|
||||
|
||||
Reference in New Issue
Block a user