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:
2026-07-12 16:14:44 +02:00
parent 873e2c91f3
commit 77bf840465
5 changed files with 864 additions and 7 deletions

View File

@@ -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);
}
}
""");
}
}

View File

@@ -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;
}
}

View File

@@ -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" : [ {