diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/CallChainEnricher.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/CallChainEnricher.java index 566d9c9..b0a12bf 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/CallChainEnricher.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/CallChainEnricher.java @@ -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 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 buildCallChainsForMachine( + AnalysisResult result, + CodebaseContext context, + CodebaseIntelligenceProvider intelligence) { + if (result.getMetadata() == null || intelligence == null) { + return null; + } + List entryPoints = result.getMetadata().getEntryPoints(); + if (entryPoints == null || entryPoints.isEmpty()) { + return null; + } StateMachineTypeResolver.MachineTypes machineTypes = StateMachineTypeResolver.resolveTypes(result.getName(), context); - List canonicalTriggers = intelligence.findTriggerPoints().stream() - .map(trigger -> MachineEnumCanonicalizer.canonicalizeTriggerPoint(trigger, machineTypes)) - .collect(Collectors.toList()); List scopedTriggers = MachineScopeFilter.filterTriggersForMachine( - canonicalTriggers, result.getName(), context); + intelligence.findTriggerPoints().stream() + .map(trigger -> MachineEnumCanonicalizer.canonicalizeTriggerPoint(trigger, machineTypes)) + .collect(Collectors.toList()), + result.getName(), + context); - List chains = intelligence.findCallChains( - result.getMetadata().getEntryPoints(), - scopedTriggers - ); + List 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); } } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/TransitionLinkerEnricher.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/TransitionLinkerEnricher.java index fcef81a..d3089a2 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/TransitionLinkerEnricher.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/TransitionLinkerEnricher.java @@ -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 updatedChains = new ArrayList<>(); List 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; diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizer.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizer.java index ce87f97..8fff263 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizer.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizer.java @@ -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 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 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 polymorphicEventsFromTransitions( + List machineTransitions, + String eventTypeFqn) { + if (machineTransitions == null || machineTransitions.isEmpty() || eventTypeFqn == null) { + return List.of(); + } + List 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 polymorphicEvents) { + return polymorphicEvents != null + && polymorphicEvents.stream() + .anyMatch(pe -> classifyTriggerEvent(pe) == TriggerEventKind.CANONICAL_ENUM); + } + + private static List allPackageCanonicalEnumConstants(String eventTypeFqn, CodebaseContext context) { + if (eventTypeFqn == null || eventTypeFqn.isBlank() || context == null) { + return List.of(); + } + String enumType = stripGenerics(eventTypeFqn); + List enumValues = context.getEnumValues(enumType); + if (enumValues == null || enumValues.isEmpty()) { + return List.of(); + } + List 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; diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AbstractCallGraphEngine.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AbstractCallGraphEngine.java index 10c8cbd..597db6d 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AbstractCallGraphEngine.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AbstractCallGraphEngine.java @@ -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(" pe != null && pe.startsWith(" path, + Map> callGraph) { + if (isExternalParameter(entryMethod, finalParamName)) { + return true; + } + Map bindings = pathBindingEvaluator.traceBindings(path, callGraph, pathFinder); + for (String boundName : bindings.keySet()) { + if (isExternalParameter(entryMethod, boundName)) { + return true; + } + } + return false; } private List resolveKeyedMapLookup( @@ -2860,7 +2883,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { if (hasConcrete) { narrowed.removeIf(pe -> pe != null && pe.startsWith(" pe != null && pe.startsWith(" refreshed = CallChainEnricher.buildCallChainsForMachine(result, context, intelligence); + if (refreshed != null) { + List 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 mergeCallChainsByEntryPoint( + List existing, + List refreshed) { + Map 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 enrichers, AnalysisResult result, diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/service/ExportService.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/service/ExportService.java index 6edac43..603826c 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/service/ExportService.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/service/ExportService.java @@ -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); diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/DispatcherExternalFalseMatchedTransitionsRegressionTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/DispatcherExternalFalseMatchedTransitionsRegressionTest.java new file mode 100644 index 0000000..ae621c0 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/DispatcherExternalFalseMatchedTransitionsRegressionTest.java @@ -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"); + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/EnricherPipelineRegressionTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/EnricherPipelineRegressionTest.java index cc84622..a25bf2a 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/EnricherPipelineRegressionTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/EnricherPipelineRegressionTest.java @@ -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); diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizerTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizerTest.java index 3b77e3c..5d0a49b 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizerTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizerTest.java @@ -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); diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/DispatcherEndpointTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/DispatcherEndpointTest.java index bd924b4..e44ebc5 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/DispatcherEndpointTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/DispatcherEndpointTest.java @@ -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 { + } + """); 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); } } diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/EnterpriseDispatcherMatchedTransitionsRegressionTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/EnterpriseDispatcherMatchedTransitionsRegressionTest.java new file mode 100644 index 0000000..fc21931 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/EnterpriseDispatcherMatchedTransitionsRegressionTest.java @@ -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 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; + } +}