Fix empty matchedTransitions for dynamic dispatcher call chains.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.enricher;
|
||||
|
||||
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.model.Event;
|
||||
import click.kamil.springstatemachineexporter.model.State;
|
||||
import click.kamil.springstatemachineexporter.model.Transition;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Regression: dispatcher REST chains with {@code external=false}, dynamic trigger event, and empty
|
||||
* {@code polymorphicEvents} must still link transitions after package-canonical FQN normalization.
|
||||
*/
|
||||
class DispatcherExternalFalseMatchedTransitionsRegressionTest {
|
||||
|
||||
private final TransitionLinkerEnricher enricher = new TransitionLinkerEnricher();
|
||||
|
||||
@Test
|
||||
void shouldLinkWhenExternalFalseDynamicTriggerAndPolymorphicEventsWereLost() {
|
||||
Transition pay = new Transition();
|
||||
pay.setSourceStates(List.of(State.of("OrderState.NEW", "com.example.order.OrderState.NEW")));
|
||||
pay.setTargetStates(List.of(State.of("OrderState.PAID", "com.example.order.OrderState.PAID")));
|
||||
pay.setEvent(Event.of("OrderEvent.PAY", "com.example.order.OrderEvent.PAY"));
|
||||
|
||||
CallChain chain = CallChain.builder()
|
||||
.triggerPoint(TriggerPoint.builder()
|
||||
.event("event")
|
||||
.external(false)
|
||||
.polymorphicEvents(null)
|
||||
.constraint("\"ORDER\".equalsIgnoreCase(machineType)")
|
||||
.build())
|
||||
.build();
|
||||
|
||||
AnalysisResult result = AnalysisResult.builder()
|
||||
.name("com.example.config.OrderStateMachineConfiguration")
|
||||
.stateTypeFqn("com.example.order.OrderState")
|
||||
.eventTypeFqn("com.example.order.OrderEvent")
|
||||
.transitions(List.of(pay))
|
||||
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
|
||||
.build();
|
||||
|
||||
enricher.enrich(result, null, null);
|
||||
|
||||
assertThat(result.getMetadata().getCallChains().get(0).getMatchedTransitions())
|
||||
.hasSize(1)
|
||||
.first()
|
||||
.extracting("event")
|
||||
.isEqualTo("com.example.order.OrderEvent.PAY");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldLinkWhenPolymorphicEventsUseWrongEnumPrefixAfterFqnNormalization() {
|
||||
Transition pay = new Transition();
|
||||
pay.setSourceStates(List.of(State.of("OrderState.NEW", "com.example.order.OrderState.NEW")));
|
||||
pay.setTargetStates(List.of(State.of("OrderState.PAID", "com.example.order.OrderState.PAID")));
|
||||
pay.setEvent(Event.of("OrderEvent.PAY", "com.example.order.OrderEvent.PAY"));
|
||||
|
||||
CallChain chain = CallChain.builder()
|
||||
.triggerPoint(TriggerPoint.builder()
|
||||
.event("e")
|
||||
.external(false)
|
||||
.polymorphicEvents(List.of("OrderEvents.PAY"))
|
||||
.build())
|
||||
.build();
|
||||
|
||||
AnalysisResult result = AnalysisResult.builder()
|
||||
.name("com.example.config.OrderStateMachineConfiguration")
|
||||
.stateTypeFqn("com.example.order.OrderState")
|
||||
.eventTypeFqn("com.example.order.OrderEvent")
|
||||
.transitions(List.of(pay))
|
||||
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
|
||||
.build();
|
||||
|
||||
enricher.enrich(result, null, null);
|
||||
|
||||
assertThat(result.getMetadata().getCallChains().get(0).getMatchedTransitions())
|
||||
.hasSize(1)
|
||||
.first()
|
||||
.extracting("event")
|
||||
.isEqualTo("com.example.order.OrderEvent.PAY");
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,12 @@ class EnricherPipelineRegressionTest {
|
||||
.build();
|
||||
|
||||
CallChain rawChain = CallChain.builder()
|
||||
.entryPoint(EntryPoint.builder()
|
||||
.type(EntryPoint.Type.REST)
|
||||
.name("POST /api/orders/pay")
|
||||
.className("com.example.web.OrderController")
|
||||
.methodName("pay")
|
||||
.build())
|
||||
.triggerPoint(rawTrigger)
|
||||
.build();
|
||||
|
||||
@@ -74,12 +80,12 @@ class EnricherPipelineRegressionTest {
|
||||
.name("com.example.config.OrderStateMachineConfiguration")
|
||||
.transitions(List.of(transition))
|
||||
.metadata(CodebaseMetadata.builder()
|
||||
.entryPoints(List.of())
|
||||
.entryPoints(List.of(rawChain.getEntryPoint()))
|
||||
.triggers(List.of(rawTrigger))
|
||||
.callChains(List.of(rawChain))
|
||||
.build())
|
||||
.build();
|
||||
|
||||
new CallChainEnricher().enrich(result, context, intelligence);
|
||||
new TriggerCanonicalizationEnricher().enrich(result, context, intelligence);
|
||||
new TransitionLinkerEnricher().enrich(result, context, intelligence);
|
||||
|
||||
|
||||
@@ -233,6 +233,44 @@ class MachineEnumCanonicalizerTest {
|
||||
.isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRemapMismatchedEnumPrefixToMachineEventType(@TempDir Path tempDir) throws IOException {
|
||||
writeSampleConfig(tempDir);
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.scan(tempDir);
|
||||
|
||||
StateMachineTypeResolver.MachineTypes types = StateMachineTypeResolver.resolveTypes(
|
||||
"com.example.config.OrderStateMachineConfiguration", context);
|
||||
|
||||
assertThat(MachineEnumCanonicalizer.canonicalizeLabel("OrderEvents.PAY", types.eventTypeFqn()))
|
||||
.isEqualTo("com.example.order.OrderEvent.PAY");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldInferPolymorphicEventsFromTransitionsWhenContextIsUnavailable() {
|
||||
Transition pay = new Transition();
|
||||
pay.setEvent(Event.of("OrderEvent.PAY", "com.example.order.OrderEvent.PAY"));
|
||||
Transition ship = new Transition();
|
||||
ship.setEvent(Event.of("OrderEvent.SHIP", "com.example.order.OrderEvent.SHIP"));
|
||||
|
||||
TriggerPoint trigger = TriggerPoint.builder()
|
||||
.event("OrderEvent.valueOf(eventString)")
|
||||
.build();
|
||||
|
||||
StateMachineTypeResolver.MachineTypes types = new StateMachineTypeResolver.MachineTypes(
|
||||
"com.example.order.OrderState", "com.example.order.OrderEvent");
|
||||
|
||||
TriggerPoint linked = MachineEnumCanonicalizer.ensureCallChainPolymorphicEvents(
|
||||
trigger, types, null, List.of(pay, ship));
|
||||
|
||||
assertThat(linked.getPolymorphicEvents())
|
||||
.containsExactlyInAnyOrder(
|
||||
"com.example.order.OrderEvent.PAY",
|
||||
"com.example.order.OrderEvent.SHIP");
|
||||
assertThat(linked.isAmbiguous()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldDedupeStatesByCanonicalFullIdentifier(@TempDir Path tempDir) throws IOException {
|
||||
writeSampleConfig(tempDir);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.service;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.enricher.TransitionLinkerEnricher;
|
||||
import click.kamil.springstatemachineexporter.analysis.enricher.TriggerCanonicalizationEnricher;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.*;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import click.kamil.springstatemachineexporter.model.Event;
|
||||
@@ -80,7 +81,7 @@ class DispatcherEndpointTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNotLinkTransitionsForExternalGenericDispatchEndpoint(@TempDir Path tempDir) throws Exception {
|
||||
void shouldLinkTransitionsForGenericDispatchWhenPolymorphicEventsAreExpanded(@TempDir Path tempDir) throws Exception {
|
||||
String source = """
|
||||
package com.example;
|
||||
public class OrderController {
|
||||
@@ -99,9 +100,20 @@ class DispatcherEndpointTest {
|
||||
}
|
||||
}
|
||||
enum OrderEvent { PAY, SHIP }
|
||||
enum OrderState { NEW, PAID, SHIPPED }
|
||||
class StateMachine { public void sendEvent(OrderEvent e) {} }
|
||||
""";
|
||||
Files.writeString(tempDir.resolve("App.java"), source);
|
||||
Files.createDirectories(tempDir.resolve("com/example/config"));
|
||||
Files.writeString(tempDir.resolve("com/example/config/OrderStateMachineConfig.java"),
|
||||
"""
|
||||
package com.example.config;
|
||||
import com.example.OrderEvent;
|
||||
import com.example.OrderState;
|
||||
import org.springframework.statemachine.config.EnumStateMachineConfigurerAdapter;
|
||||
public class OrderStateMachineConfig extends EnumStateMachineConfigurerAdapter<OrderState, OrderEvent> {
|
||||
}
|
||||
""");
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.scan(tempDir);
|
||||
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
|
||||
@@ -117,20 +129,26 @@ class DispatcherEndpointTest {
|
||||
.build();
|
||||
CallChain chain = engine.findChains(List.of(entry), List.of(trigger)).get(0);
|
||||
|
||||
assertThat(chain.getTriggerPoint().getPolymorphicEvents()).isEmpty();
|
||||
|
||||
Transition payT = new Transition();
|
||||
payT.setSourceStates(List.of(State.of("NEW", "OrderState.NEW")));
|
||||
payT.setTargetStates(List.of(State.of("PAID", "OrderState.PAID")));
|
||||
payT.setSourceStates(List.of(State.of("OrderState.NEW", "com.example.OrderState.NEW")));
|
||||
payT.setTargetStates(List.of(State.of("OrderState.PAID", "com.example.OrderState.PAID")));
|
||||
payT.setEvent(Event.of("OrderEvent.PAY", "com.example.OrderEvent.PAY"));
|
||||
Transition shipT = new Transition();
|
||||
shipT.setSourceStates(List.of(State.of("OrderState.PAID", "com.example.OrderState.PAID")));
|
||||
shipT.setTargetStates(List.of(State.of("OrderState.SHIPPED", "com.example.OrderState.SHIPPED")));
|
||||
shipT.setEvent(Event.of("OrderEvent.SHIP", "com.example.OrderEvent.SHIP"));
|
||||
|
||||
AnalysisResult result = AnalysisResult.builder()
|
||||
.name("OrderStateMachineConfig")
|
||||
.transitions(List.of(payT))
|
||||
.name("com.example.config.OrderStateMachineConfig")
|
||||
.transitions(List.of(payT, shipT))
|
||||
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
|
||||
.build();
|
||||
new TransitionLinkerEnricher().enrich(result, null, null);
|
||||
new TriggerCanonicalizationEnricher().enrich(result, context, null);
|
||||
new TransitionLinkerEnricher().enrich(result, context, null);
|
||||
|
||||
assertThat(result.getMetadata().getCallChains().get(0).getMatchedTransitions()).isNull();
|
||||
TriggerPoint linkedTrigger = result.getMetadata().getCallChains().get(0).getTriggerPoint();
|
||||
assertThat(linkedTrigger.getPolymorphicEvents())
|
||||
.contains("com.example.OrderEvent.PAY", "com.example.OrderEvent.SHIP");
|
||||
assertThat(result.getMetadata().getCallChains().get(0).getMatchedTransitions()).hasSize(2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.service;
|
||||
|
||||
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.exporter.ExportOptions;
|
||||
import click.kamil.springstatemachineexporter.exporter.JsonExporter;
|
||||
import click.kamil.springstatemachineexporter.service.ExportService;
|
||||
import click.kamil.springstatemachineexporter.service.JsonImportService;
|
||||
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 static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Regression for REST dispatcher chains that lost {@code polymorphicEvents} during JSON round-trip.
|
||||
*/
|
||||
class EnterpriseDispatcherMatchedTransitionsRegressionTest {
|
||||
|
||||
@Test
|
||||
void jsonReExportWithSourceShouldRestoreDispatcherMatchedTransitions(@TempDir Path tempDir) throws Exception {
|
||||
Path enterprise = findProjectRoot().resolve("state_machines/state_machine_enterprise");
|
||||
|
||||
Path astOut = tempDir.resolve("ast");
|
||||
ExportService exportService = new ExportService(List.of(new JsonExporter()));
|
||||
exportService.runExporter(enterprise, astOut, List.of("json"), true, List.of(), null, null,
|
||||
click.kamil.springstatemachineexporter.exporter.EnumFormat.fn,
|
||||
click.kamil.springstatemachineexporter.exporter.EnumFormat.fn);
|
||||
|
||||
Path astJson = astOut.resolve(
|
||||
"click.kamil.enterprise.machines.order.OrderStateMachineConfiguration/click.kamil.enterprise.machines.order.OrderStateMachineConfiguration.json");
|
||||
|
||||
AnalysisResult imported = new JsonImportService().importAnalysisResult(astJson);
|
||||
List<CallChain> corruptedChains = imported.getMetadata().getCallChains().stream()
|
||||
.map(chain -> {
|
||||
if (chain.getTriggerPoint() == null
|
||||
|| chain.getMethodChain() == null
|
||||
|| !chain.getMethodChain().stream()
|
||||
.anyMatch(m -> m.contains("StateMachineDispatcher.dispatch"))) {
|
||||
return chain;
|
||||
}
|
||||
return chain.toBuilder()
|
||||
.triggerPoint(chain.getTriggerPoint().toBuilder()
|
||||
.event("event")
|
||||
.external(false)
|
||||
.polymorphicEvents(null)
|
||||
.build())
|
||||
.matchedTransitions(null)
|
||||
.build();
|
||||
})
|
||||
.toList();
|
||||
imported.setMetadata(CodebaseMetadata.builder()
|
||||
.triggers(imported.getMetadata().getTriggers())
|
||||
.entryPoints(imported.getMetadata().getEntryPoints())
|
||||
.callChains(corruptedChains)
|
||||
.properties(imported.getMetadata().getProperties())
|
||||
.build());
|
||||
|
||||
Path brokenJson = tempDir.resolve("broken-order-machine.json");
|
||||
Files.writeString(brokenJson, new JsonExporter().export(imported, ExportOptions.builder().build()));
|
||||
|
||||
Path outputDir = tempDir.resolve("out");
|
||||
exportService.runJsonExporter(
|
||||
brokenJson, outputDir, List.of("json"), List.of(),
|
||||
click.kamil.springstatemachineexporter.exporter.EnumFormat.fn,
|
||||
click.kamil.springstatemachineexporter.exporter.EnumFormat.fn,
|
||||
enterprise);
|
||||
|
||||
Path finalized = outputDir.resolve(
|
||||
"click.kamil.enterprise.machines.order.OrderStateMachineConfiguration/click.kamil.enterprise.machines.order.OrderStateMachineConfiguration.json");
|
||||
AnalysisResult roundTripped = new JsonImportService().importAnalysisResult(finalized);
|
||||
|
||||
assertThat(roundTripped.getMetadata().getCallChains())
|
||||
.as("call chains after re-export")
|
||||
.isNotEmpty();
|
||||
|
||||
CallChain chain = roundTripped.getMetadata().getCallChains().stream()
|
||||
.filter(c -> c.getMethodChain() != null && c.getMethodChain().stream()
|
||||
.anyMatch(m -> m.contains("StateMachineDispatcher.dispatch")))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new IllegalStateException(
|
||||
"no dispatcher chain among: "
|
||||
+ roundTripped.getMetadata().getCallChains().stream()
|
||||
.map(c -> c.getEntryPoint() != null ? c.getEntryPoint().getName() : "null")
|
||||
.toList()));
|
||||
|
||||
assertThat(chain.getTriggerPoint().getPolymorphicEvents())
|
||||
.as("polymorphicEvents on %s", chain.getEntryPoint().getName())
|
||||
.isNotNull()
|
||||
.isNotEmpty();
|
||||
assertThat(chain.getTriggerPoint().isExternal())
|
||||
.as("external flag on %s", chain.getEntryPoint().getName())
|
||||
.isTrue();
|
||||
assertThat(chain.getMatchedTransitions())
|
||||
.as("matchedTransitions on %s", chain.getEntryPoint().getName())
|
||||
.isNotNull()
|
||||
.isNotEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void astExportTransitionEndpointShouldLinkMatchedTransitions(@TempDir Path tempDir) throws Exception {
|
||||
Path enterprise = findProjectRoot().resolve("state_machines/state_machine_enterprise");
|
||||
ExportService exportService = new ExportService(List.of(new JsonExporter()));
|
||||
exportService.runExporter(enterprise, tempDir, List.of("json"), true, List.of(), null, null,
|
||||
click.kamil.springstatemachineexporter.exporter.EnumFormat.fn,
|
||||
click.kamil.springstatemachineexporter.exporter.EnumFormat.fn);
|
||||
|
||||
JsonNode json = readMachineJson(tempDir, "OrderStateMachineConfiguration", new ObjectMapper());
|
||||
JsonNode chain = findTransitionEndpointChain(json);
|
||||
|
||||
assertThat(chain.path("triggerPoint").path("external").asBoolean()).isTrue();
|
||||
assertThat(chain.path("triggerPoint").path("polymorphicEvents").isArray()).isTrue();
|
||||
assertThat(chain.path("triggerPoint").path("polymorphicEvents")).isNotEmpty();
|
||||
assertThat(chain.path("matchedTransitions").isArray()).isTrue();
|
||||
assertThat(chain.path("matchedTransitions")).isNotEmpty();
|
||||
}
|
||||
|
||||
private static JsonNode findTransitionEndpointChain(JsonNode machineJson) {
|
||||
for (JsonNode chain : machineJson.path("metadata").path("callChains")) {
|
||||
if ("POST /api/machine/{machineType}/transition/{event}".equals(
|
||||
chain.path("entryPoint").path("name").asText())) {
|
||||
return chain;
|
||||
}
|
||||
}
|
||||
throw new IllegalStateException("transition endpoint chain not found");
|
||||
}
|
||||
|
||||
private static JsonNode readMachineJson(Path outputDir, String configBaseName, ObjectMapper mapper)
|
||||
throws Exception {
|
||||
Path machineDir;
|
||||
try (var stream = Files.list(outputDir)) {
|
||||
machineDir = stream
|
||||
.filter(Files::isDirectory)
|
||||
.filter(path -> path.getFileName().toString().endsWith(configBaseName))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new IllegalStateException("No output for " + configBaseName));
|
||||
}
|
||||
return mapper.readTree(machineDir.resolve(machineDir.getFileName() + ".json").toFile());
|
||||
}
|
||||
|
||||
private static Path findProjectRoot() {
|
||||
Path current = Path.of(".").toAbsolutePath();
|
||||
while (current != null && !Files.exists(current.resolve("settings.gradle"))) {
|
||||
current = current.getParent();
|
||||
}
|
||||
return current;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user