Fix empty matchedTransitions for dynamic dispatcher call chains.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-12 13:04:07 +02:00
parent 7ffd157105
commit 422ea94b28
11 changed files with 575 additions and 32 deletions

View File

@@ -3,6 +3,7 @@ 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.EntryPoint;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver;
@@ -19,20 +20,43 @@ public class CallChainEnricher implements AnalysisEnricher {
@Override
public void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
log.info("Enriching {} with call chains", result.getName());
List<CallChain> chains = buildCallChainsForMachine(result, context, intelligence);
if (chains == null) {
return;
}
result.addMetadata(CodebaseMetadata.builder()
.callChains(chains)
.build());
}
/**
* Re-resolves call chains from the codebase call graph for the given machine.
* Used on JSON re-export when source is available to refresh {@code polymorphicEvents},
* {@code external}, and branch {@code constraint} on trigger points.
*/
public static List<CallChain> buildCallChainsForMachine(
AnalysisResult result,
CodebaseContext context,
CodebaseIntelligenceProvider intelligence) {
if (result.getMetadata() == null || intelligence == null) {
return null;
}
List<EntryPoint> entryPoints = result.getMetadata().getEntryPoints();
if (entryPoints == null || entryPoints.isEmpty()) {
return null;
}
StateMachineTypeResolver.MachineTypes machineTypes =
StateMachineTypeResolver.resolveTypes(result.getName(), context);
List<TriggerPoint> canonicalTriggers = intelligence.findTriggerPoints().stream()
.map(trigger -> MachineEnumCanonicalizer.canonicalizeTriggerPoint(trigger, machineTypes))
.collect(Collectors.toList());
List<TriggerPoint> scopedTriggers = MachineScopeFilter.filterTriggersForMachine(
canonicalTriggers, result.getName(), context);
intelligence.findTriggerPoints().stream()
.map(trigger -> MachineEnumCanonicalizer.canonicalizeTriggerPoint(trigger, machineTypes))
.collect(Collectors.toList()),
result.getName(),
context);
List<CallChain> chains = intelligence.findCallChains(
result.getMetadata().getEntryPoints(),
scopedTriggers
);
List<CallChain> chains = intelligence.findCallChains(entryPoints, scopedTriggers);
chains = chains.stream()
.map(chain -> chain.getTriggerPoint() == null
? chain
@@ -41,10 +65,6 @@ public class CallChainEnricher implements AnalysisEnricher {
chain.getTriggerPoint(), machineTypes))
.build())
.collect(Collectors.toList());
chains = MachineScopeFilter.filterCallChainsForMachine(chains, result.getName(), context);
result.addMetadata(CodebaseMetadata.builder()
.callChains(chains)
.build());
return MachineScopeFilter.filterCallChainsForMachine(chains, result.getName(), context);
}
}

View File

@@ -21,6 +21,8 @@ import click.kamil.springstatemachineexporter.analysis.enricher.matching.StrictF
import click.kamil.springstatemachineexporter.analysis.enricher.routing.BeanResolutionEngine;
import click.kamil.springstatemachineexporter.analysis.enricher.routing.HeuristicBeanResolutionEngine;
import click.kamil.springstatemachineexporter.analysis.resolver.BooleanConstraintEvaluator;
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver;
public class TransitionLinkerEnricher implements AnalysisEnricher {
@@ -35,6 +37,13 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
List<CallChain> updatedChains = new ArrayList<>();
List<Transition> stateMachineTransitions = result.getTransitions();
StateMachineTypeResolver.MachineTypes machineTypes =
StateMachineTypeResolver.resolveTypes(result.getName(), context);
if (machineTypes.stateTypeFqn() == null && machineTypes.eventTypeFqn() == null
&& (result.getStateTypeFqn() != null || result.getEventTypeFqn() != null)) {
machineTypes = new StateMachineTypeResolver.MachineTypes(
result.getStateTypeFqn(), result.getEventTypeFqn());
}
for (CallChain chain : result.getMetadata().getCallChains()) {
TriggerPoint tp = chain.getTriggerPoint();
@@ -43,6 +52,10 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
continue;
}
tp = MachineEnumCanonicalizer.ensureCallChainPolymorphicEvents(
tp, machineTypes, context, stateMachineTransitions);
tp = markRestEndpointExternal(tp, chain);
if (LifecycleTriggerMarkers.isLifecycle(tp.getEvent())) {
updatedChains.add(chain);
continue;
@@ -90,10 +103,16 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
}
if (!matched.isEmpty()) {
CallChain newChain = chain.toBuilder().matchedTransitions(matched).build();
CallChain newChain = chain.toBuilder()
.triggerPoint(tp)
.matchedTransitions(matched)
.build();
updatedChains.add(newChain);
} else {
updatedChains.add(chain);
updatedChains.add(chain.toBuilder()
.triggerPoint(tp)
.matchedTransitions(null)
.build());
}
}
@@ -179,6 +198,25 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
return event.fullIdentifier() != null ? event.fullIdentifier() : event.rawName();
}
private TriggerPoint markRestEndpointExternal(TriggerPoint trigger, CallChain chain) {
if (trigger == null || trigger.isExternal() || chain.getEntryPoint() == null) {
return trigger;
}
EntryPoint entryPoint = chain.getEntryPoint();
if (entryPoint.getType() != EntryPoint.Type.REST) {
return trigger;
}
String endpointName = entryPoint.getName();
if (endpointName == null || !endpointName.contains("{")) {
return trigger;
}
if (MachineEnumCanonicalizer.classifyTriggerEvent(trigger.getEvent())
!= MachineEnumCanonicalizer.TriggerEventKind.DYNAMIC_EXPRESSION) {
return trigger;
}
return trigger.toBuilder().external(true).build();
}
private boolean isGuardedContains(String smEvent, String triggerEvent) {
if (smEvent == null || triggerEvent == null) return false;

View File

@@ -152,6 +152,92 @@ public final class MachineEnumCanonicalizer {
return canonical.toBuilder().polymorphicEvents(expanded).build();
}
/**
* When a call-chain trigger uses a dynamic fire-site expression ({@code valueOf}, variable, getter)
* but has no concrete {@code polymorphicEvents}, infer all machine enum events so transition linking
* can proceed. Used only for {@code callChains[].triggerPoint}, not {@code metadata.triggers}.
*/
public static TriggerPoint ensureCallChainPolymorphicEvents(
TriggerPoint trigger,
StateMachineTypeResolver.MachineTypes machineTypes,
CodebaseContext context) {
return ensureCallChainPolymorphicEvents(trigger, machineTypes, context, null);
}
public static TriggerPoint ensureCallChainPolymorphicEvents(
TriggerPoint trigger,
StateMachineTypeResolver.MachineTypes machineTypes,
CodebaseContext context,
List<Transition> machineTransitions) {
TriggerPoint expanded = canonicalizeAndExpandTriggerPoint(trigger, machineTypes, context);
if (expanded == null || machineTypes == null || machineTypes.eventTypeFqn() == null) {
return expanded;
}
if (hasConcretePolymorphicEvents(expanded.getPolymorphicEvents())) {
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());
}
if (machineEvents.isEmpty()) {
return expanded;
}
return expanded.toBuilder().polymorphicEvents(machineEvents).ambiguous(true).build();
}
static List<String> polymorphicEventsFromTransitions(
List<Transition> machineTransitions,
String eventTypeFqn) {
if (machineTransitions == null || machineTransitions.isEmpty() || eventTypeFqn == null) {
return List.of();
}
List<String> events = new ArrayList<>();
for (Transition transition : machineTransitions) {
if (transition.getEvent() == null) {
continue;
}
String identifier = transition.getEvent().fullIdentifier() != null
? transition.getEvent().fullIdentifier()
: transition.getEvent().rawName();
if (identifier == null || identifier.isBlank()) {
continue;
}
String canonical = canonicalizeLabel(identifier, eventTypeFqn);
if (classifyTriggerEvent(canonical) == TriggerEventKind.CANONICAL_ENUM
&& !events.contains(canonical)) {
events.add(canonical);
}
}
return events;
}
private static boolean hasConcretePolymorphicEvents(List<String> polymorphicEvents) {
return polymorphicEvents != null
&& polymorphicEvents.stream()
.anyMatch(pe -> classifyTriggerEvent(pe) == TriggerEventKind.CANONICAL_ENUM);
}
private static List<String> allPackageCanonicalEnumConstants(String eventTypeFqn, CodebaseContext context) {
if (eventTypeFqn == null || eventTypeFqn.isBlank() || context == null) {
return List.of();
}
String enumType = stripGenerics(eventTypeFqn);
List<String> enumValues = context.getEnumValues(enumType);
if (enumValues == null || enumValues.isEmpty()) {
return List.of();
}
List<String> canonical = new ArrayList<>();
for (String value : enumValues) {
String constant = value.contains(".") ? value.substring(value.lastIndexOf('.') + 1) : value;
canonical.add(enumType + "." + constant);
}
return canonical;
}
public static String qualifyEventIdentifier(String event, String eventTypeFqn) {
if (event == null || eventTypeFqn == null || event.isEmpty()) {
return event;
@@ -419,6 +505,12 @@ public final class MachineEnumCanonicalizer {
return enumTypeFqn + "." + constant;
}
if (typePart != null && !typePart.contains(".") && !enumTypesMatch(enumTypeFqn, typePart)
&& importStyleEnumTypeMatches(typePart, enumTypeFqn)
&& constant.matches("[A-Z_][A-Z0-9_]*")) {
return enumTypeFqn + "." + constant;
}
if (typePart == null && Character.isUpperCase(stripped.charAt(0)) && !stripped.contains(".")) {
return enumTypeFqn + "." + stripped;
}
@@ -446,6 +538,11 @@ public final class MachineEnumCanonicalizer {
return ref.substring(0, dot);
}
private static boolean importStyleEnumTypeMatches(String typePart, String enumTypeFqn) {
String simple = simpleName(enumTypeFqn);
return simple.equals(typePart) || (simple + "s").equals(typePart);
}
static boolean enumTypesMatch(String type1, String type2) {
if (type1 == null || type2 == null) {
return false;

View File

@@ -12,6 +12,7 @@ import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.jdt.core.dom.*;
@@ -177,7 +178,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
TriggerPoint resolved = resolveTriggerPointParametersOriginal(tp, path, callGraph, finalParamNameRef);
if (resolved == null) return null;
String entryMethod = path.isEmpty() ? (tp.getClassName() + "." + tp.getMethodName()) : path.get(0);
boolean isExternal = isExternalParameter(entryMethod, finalParamNameRef[0]);
boolean isExternal = isExternalTrigger(entryMethod, finalParamNameRef[0], path, callGraph);
String pathConstraint = extractPathConstraints(path, callGraph);
String triggerConstraint = resolved.getConstraint();
@@ -1021,7 +1022,11 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
polymorphicEvents = narrowPolymorphicEventsWithPathBindings(polymorphicEvents, path, callGraph, tp);
Expression runtimeExpr = exprNode instanceof Expression expression ? expression : null;
if (isRuntimeEnumParameter(runtimeExpr) || tp.isExternal()) {
boolean hasConcretePolymorphic = polymorphicEvents.stream()
.anyMatch(pe -> pe != null && !pe.startsWith("<SYMBOLIC:"));
if (hasConcretePolymorphic
&& (isRuntimeEnumParameter(runtimeExpr) || tp.isExternal()
|| MachineEnumCanonicalizer.isDynamicTriggerExpression(resolvedValue))) {
polymorphicEvents.removeIf(pe -> pe != null && pe.startsWith("<SYMBOLIC:"));
}
@@ -2730,10 +2735,28 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
if (!(exprNode instanceof MethodInvocation mi)) {
return false;
}
if (!"valueOf".equals(mi.getName().getIdentifier()) || mi.arguments().size() != 1) {
if (!"valueOf".equals(mi.getName().getIdentifier()) || mi.arguments().isEmpty()) {
return false;
}
return mi.arguments().get(0) instanceof SimpleName;
Expression arg = (Expression) mi.arguments().get(0);
return !(arg instanceof StringLiteral);
}
private boolean isExternalTrigger(
String entryMethod,
String finalParamName,
List<String> path,
Map<String, List<CallEdge>> callGraph) {
if (isExternalParameter(entryMethod, finalParamName)) {
return true;
}
Map<String, String> bindings = pathBindingEvaluator.traceBindings(path, callGraph, pathFinder);
for (String boundName : bindings.keySet()) {
if (isExternalParameter(entryMethod, boundName)) {
return true;
}
}
return false;
}
private List<String> resolveKeyedMapLookup(
@@ -2860,7 +2883,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
if (hasConcrete) {
narrowed.removeIf(pe -> pe != null && pe.startsWith("<SYMBOLIC:"));
}
if (tp.isExternal()) {
if (tp.isExternal() && hasConcrete) {
narrowed.removeIf(pe -> pe != null && pe.startsWith("<SYMBOLIC:"));
}
return narrowed;

View File

@@ -2,6 +2,13 @@ package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.enricher.AnalysisEnricher;
import click.kamil.springstatemachineexporter.analysis.enricher.CallChainEnricher;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import click.kamil.springstatemachineexporter.analysis.enricher.EntryPointEnricher;
import click.kamil.springstatemachineexporter.analysis.enricher.PropertyEnricher;
import click.kamil.springstatemachineexporter.analysis.enricher.TriggerCanonicalizationEnricher;
@@ -67,6 +74,52 @@ public class EnrichmentService {
intelligence);
}
/**
* JSON re-export path: when source and entry points are available, rebuild call chains from the
* call graph so dispatcher triggers regain {@code polymorphicEvents}/{@code external}, then relink.
*/
public void refreshCallChainsAndRelink(
AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
if (result.getMetadata() != null && context != null && intelligence != null) {
List<CallChain> refreshed = CallChainEnricher.buildCallChainsForMachine(result, context, intelligence);
if (refreshed != null) {
List<CallChain> merged = mergeCallChainsByEntryPoint(result.getMetadata().getCallChains(), refreshed);
result.setMetadata(click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata.builder()
.triggers(result.getMetadata().getTriggers())
.entryPoints(result.getMetadata().getEntryPoints())
.callChains(merged)
.properties(result.getMetadata().getProperties())
.build());
}
}
relinkAfterPropertyResolution(result, context, intelligence);
}
private static List<CallChain> mergeCallChainsByEntryPoint(
List<CallChain> existing,
List<CallChain> refreshed) {
Map<String, CallChain> byEntryPoint = new LinkedHashMap<>();
if (existing != null) {
for (CallChain chain : existing) {
byEntryPoint.putIfAbsent(entryPointKey(chain.getEntryPoint()), chain);
}
}
for (CallChain chain : refreshed) {
byEntryPoint.put(entryPointKey(chain.getEntryPoint()), chain);
}
return new ArrayList<>(byEntryPoint.values());
}
private static String entryPointKey(EntryPoint entryPoint) {
if (entryPoint == null) {
return "";
}
if (entryPoint.getName() != null && !entryPoint.getName().isBlank()) {
return entryPoint.getName();
}
return entryPoint.getClassName() + "#" + entryPoint.getMethodName();
}
private static void runEnrichers(
List<AnalysisEnricher> enrichers,
AnalysisResult result,

View File

@@ -194,7 +194,15 @@ public class ExportService {
result.getEventTypeFqn(),
context);
AnalysisResultFinalizer.applyCanonicalization(result, context, machineTypes);
enrichmentService.relinkAfterPropertyResolution(result, context, null);
if (context != null && sourceRoot != null
&& result.getMetadata() != null
&& result.getMetadata().getEntryPoints() != null
&& !result.getMetadata().getEntryPoints().isEmpty()) {
JdtIntelligenceProvider intelligence = new JdtIntelligenceProvider(context, sourceRoot);
enrichmentService.refreshCallChainsAndRelink(result, context, intelligence);
} else {
enrichmentService.relinkAfterPropertyResolution(result, context, null);
}
AnalysisResultFinalizer.finalizeResult(result, context, machineTypes);
generateOutputs(outputDir, result, selectedFormats, eventFormat, stateFormat);

View File

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

View File

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

View File

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

View File

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

View File

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