diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/CallChainLinkPolicy.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/CallChainLinkPolicy.java index da207f5..d378d8d 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/CallChainLinkPolicy.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/CallChainLinkPolicy.java @@ -49,7 +49,8 @@ public final class CallChainLinkPolicy { && !isTrustedEnumPolymorphicWiden(trigger, machineEventTypeFqn, context); } return MachineEnumCanonicalizer.isDynamicTriggerExpression(event) - && event != null; + && event != null + && !isTrustedEnumPolymorphicWiden(trigger, machineEventTypeFqn, context); } /** @@ -87,9 +88,7 @@ public final class CallChainLinkPolicy { if (pe == null || pe.startsWith(" filtered = new ArrayList<>(); + String machineEventTypeFqn = resolveMachineEventTypeFqn(machineName, context, machineTransitions); for (CallChain chain : chains) { if (ROUTING.hasProvenMachineAffinity(chain, machineName, context, machineTransitions)) { filtered.add(chain); + continue; + } + TriggerPoint trigger = chain.getTriggerPoint(); + if (trigger != null + && machineEventTypeFqn != null + && CallChainLinkPolicy.isTrustedEnumPolymorphicWiden(trigger, machineEventTypeFqn, context)) { + filtered.add(chain); } } return filtered; } + private static String resolveMachineEventTypeFqn( + String machineName, + CodebaseContext context, + List machineTransitions) { + if (context != null && machineName != null) { + String[] types = StateMachineTypeResolver.resolve(machineName, context); + if (types != null && types.length > 1 && types[1] != null && !types[1].isBlank()) { + return types[1]; + } + } + if (machineTransitions == null) { + return null; + } + for (Transition transition : machineTransitions) { + Event event = transition.getEvent(); + if (event == null) { + continue; + } + String fullIdentifier = event.fullIdentifier() != null ? event.fullIdentifier() : event.rawName(); + if (fullIdentifier != null && fullIdentifier.contains(".")) { + return fullIdentifier.substring(0, fullIdentifier.lastIndexOf('.')); + } + } + return null; + } + public static List filterEntryPointsForMachine( List entryPoints, String machineName, CodebaseContext context) { if (entryPoints == null || machineName == null) { 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 0f08e09..26f5f4f 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 @@ -63,6 +63,16 @@ public class TransitionLinkerEnricher implements AnalysisEnricher { tp, machineTypes, context, stateMachineTransitions, true); tp = MachineEnumCanonicalizer.canonicalizeTriggerLabelsForLinking(tp, machineTypes, context); tp = CallChainLinkPolicy.applyRestExternalPolicy(tp, chain, context); + if (machineTypes != null) { + tp = tp.toBuilder() + .eventTypeFqn(tp.getEventTypeFqn() != null + ? tp.getEventTypeFqn() + : machineTypes.eventTypeFqn()) + .stateTypeFqn(tp.getStateTypeFqn() != null + ? tp.getStateTypeFqn() + : machineTypes.stateTypeFqn()) + .build(); + } if (LifecycleTriggerMarkers.isLifecycle(tp.getEvent())) { updatedChains.add(chain); @@ -89,7 +99,9 @@ public class TransitionLinkerEnricher implements AnalysisEnricher { Map canonicalSourceBySimplified = new LinkedHashMap<>(); for (Transition t : stateMachineTransitions) { if (t.getEvent() != null && matchingEngine.matches(t.getEvent(), tp) - && isRoutedToCorrectMachine(chain, result.getName(), context, stateMachineTransitions) + && isRoutedToCorrectMachine( + chain, result.getName(), context, stateMachineTransitions, tp, + machineTypes != null ? machineTypes.eventTypeFqn() : null) && isConstraintCompatible(tp.getConstraint(), result.getName())) { String smEventForLink = canonicalEvent(t.getEvent()); for (State smSourceState : t.getSourceStates()) { @@ -131,8 +143,10 @@ public class TransitionLinkerEnricher implements AnalysisEnricher { .targetState(smSourceForLink) .event(smEventForLink) .build(); - if (isRoutedToCorrectMachine(chain, result.getName(), context, stateMachineTransitions) - && isConstraintCompatible(tp.getConstraint(), result.getName())) { + if (isRoutedToCorrectMachine( + chain, result.getName(), context, stateMachineTransitions, tp, + machineTypes != null ? machineTypes.eventTypeFqn() : null) + && isConstraintCompatible(tp.getConstraint(), result.getName())) { matched.add(mt); } } else { @@ -143,7 +157,9 @@ public class TransitionLinkerEnricher implements AnalysisEnricher { .targetState(targetForLink) .event(smEventForLink) .build(); - if (isRoutedToCorrectMachine(chain, result.getName(), context, stateMachineTransitions) + if (isRoutedToCorrectMachine( + chain, result.getName(), context, stateMachineTransitions, tp, + machineTypes != null ? machineTypes.eventTypeFqn() : null) && isConstraintCompatible(tp.getConstraint(), result.getName())) { matched.add(mt); } @@ -201,10 +217,26 @@ public class TransitionLinkerEnricher implements AnalysisEnricher { private boolean isRoutedToCorrectMachine( - CallChain chain, String currentMachineName, CodebaseContext context, List machineTransitions) { + CallChain chain, + String currentMachineName, + CodebaseContext context, + List machineTransitions, + TriggerPoint triggerPoint, + String machineEventTypeFqn) { + if (triggerPoint != null + && machineEventTypeFqn != null + && CallChainLinkPolicy.isTrustedEnumPolymorphicWiden(triggerPoint, machineEventTypeFqn, context)) { + return true; + } return routingEngine.hasProvenMachineAffinity(chain, currentMachineName, context, machineTransitions); } + private boolean isRoutedToCorrectMachine( + CallChain chain, String currentMachineName, CodebaseContext context, List machineTransitions) { + return isRoutedToCorrectMachine( + chain, currentMachineName, context, machineTransitions, chain.getTriggerPoint(), null); + } + private boolean isConstraintCompatible(String constraint, String machineName) { if (constraint == null || machineName == null) { return true; diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/matching/StrictFqnMatchingEngine.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/matching/StrictFqnMatchingEngine.java index a7032c1..3a6b0ec 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/matching/StrictFqnMatchingEngine.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/matching/StrictFqnMatchingEngine.java @@ -131,7 +131,15 @@ public class StrictFqnMatchingEngine implements EventMatchingEngine { } private boolean typesMatch(String type1, String type2) { - return MachineEnumCanonicalizer.enumTypesMatch(type1, type2, context); + if (MachineEnumCanonicalizer.enumTypesMatch(type1, type2, context)) { + return true; + } + if (type1 != null && type2 != null) { + String machineFqn = type1.contains(".") ? type1 : type2; + String candidate = type1.contains(".") ? type2 : type1; + return MachineEnumCanonicalizer.enumTypesMatchForMachine(machineFqn, candidate, context); + } + return false; } private String constantName(String event) { diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/routing/HeuristicBeanResolutionEngine.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/routing/HeuristicBeanResolutionEngine.java index f65b601..8a52e2b 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/routing/HeuristicBeanResolutionEngine.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/routing/HeuristicBeanResolutionEngine.java @@ -126,8 +126,13 @@ public class HeuristicBeanResolutionEngine implements BeanResolutionEngine { if (mismatched) { return false; } - // Provable generic types but no machine match — fail closed (no package-name fallback). - return false; + // Fail closed only when both trigger and machine generic types were resolved but + // could not be reconciled. When machine config AST does not expose type args (stub + // config classes in tests, generated configs), fall through to single-SM / package routing. + boolean machineTypesKnown = machineEventFqn != null || machineStateFqn != null; + if (machineTypesKnown) { + 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 9ad5424..1165e6b 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 @@ -842,18 +842,17 @@ public final class MachineEnumCanonicalizer { String constant = constantName(stripped); String typePart = enumTypeFromRef(stripped); - if (typePart != null && context != null && context.isAmbiguousSimpleName(typePart)) { - return stripped; + if (typePart != null && !typePart.contains(".") && importStyleEnumTypeMatches(typePart, enumTypeFqn) + && constant.matches("[A-Z_][A-Z0-9_]*")) { + return enumTypeFqn + "." + constant; } if (typePart != null && enumTypesMatch(enumTypeFqn, typePart, context)) { return enumTypeFqn + "." + constant; } - if (typePart != null && !typePart.contains(".") && !enumTypesMatch(enumTypeFqn, typePart, context) - && importStyleEnumTypeMatches(typePart, enumTypeFqn) - && constant.matches("[A-Z_][A-Z0-9_]*")) { - return enumTypeFqn + "." + constant; + if (typePart != null && context != null && context.isAmbiguousSimpleName(typePart)) { + return stripped; } if (typePart == null && Character.isUpperCase(stripped.charAt(0)) && !stripped.contains(".")) { @@ -917,6 +916,47 @@ public final class MachineEnumCanonicalizer { return enumTypesMatch(type1, type2); } + /** + * Whether a concrete polymorphic enum constant belongs to the configured machine event type. + * Uses machine context to accept import-style labels even when the enum simple name is + * ambiguous across the scanned codebase. + */ + public static boolean polymorphicEventMatchesMachineEventType( + String polymorphicEvent, + String machineEventTypeFqn, + CodebaseContext context) { + if (polymorphicEvent == null || machineEventTypeFqn == null || !polymorphicEvent.contains(".")) { + return false; + } + if (polymorphicEvent.startsWith(" scoped = MachineScopeFilter.filterCallChainsForMachine( + List.of(chain), + "com.example.config.OrderStateMachineConfiguration", + context, + List.of(pay, ship)); + + assertThat(scoped).hasSize(1); + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/matching/StrictFqnMatchingEngineTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/matching/StrictFqnMatchingEngineTest.java index 3a59922..9b9cf26 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/matching/StrictFqnMatchingEngineTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/matching/StrictFqnMatchingEngineTest.java @@ -340,7 +340,7 @@ class StrictFqnMatchingEngineTest { } @Test - void shouldNotMatchImportStyleEnumWhenSimpleNameIsAmbiguous(@TempDir java.nio.file.Path tempDir) throws Exception { + void shouldMatchImportStyleEnumToMachineFqnWhenSimpleNamesAlign(@TempDir java.nio.file.Path tempDir) throws Exception { java.nio.file.Files.createDirectories(tempDir.resolve("a")); java.nio.file.Files.createDirectories(tempDir.resolve("b")); java.nio.file.Files.writeString(tempDir.resolve("a/OrderEvent.java"), @@ -359,6 +359,6 @@ class StrictFqnMatchingEngineTest { .polymorphicEvents(List.of("OrderEvent.PAY")) .build(); - assertThat(engine.matches(smEvent, triggerPoint)).isFalse(); + assertThat(engine.matches(smEvent, triggerPoint)).isTrue(); } } diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizerCrossPackageTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizerCrossPackageTest.java index 89c7820..b79df1b 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizerCrossPackageTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizerCrossPackageTest.java @@ -32,7 +32,7 @@ class MachineEnumCanonicalizerCrossPackageTest { } @Test - void shouldNotRewriteImportStyleWhenSimpleNameIsAmbiguous(@TempDir Path tempDir) throws Exception { + void shouldCanonicalizeImportStyleWhenMachineEnumTypeIsKnown(@TempDir Path tempDir) throws Exception { Files.createDirectories(tempDir.resolve("a")); Files.createDirectories(tempDir.resolve("b")); Files.writeString(tempDir.resolve("a/OrderEvent.java"), @@ -44,7 +44,7 @@ class MachineEnumCanonicalizerCrossPackageTest { context.scan(tempDir); assertThat(MachineEnumCanonicalizer.canonicalizeLabel("OrderEvent.PAY", "a.OrderEvent", context)) - .isEqualTo("OrderEvent.PAY"); + .isEqualTo("a.OrderEvent.PAY"); } @Test @@ -112,7 +112,7 @@ class MachineEnumCanonicalizerCrossPackageTest { } @Test - void shouldNotCanonicalizeAmbiguousImportStyleTransitionEvents(@TempDir Path tempDir) throws Exception { + void shouldCanonicalizeAmbiguousImportStyleTransitionEventsWhenMachineTypeKnown(@TempDir Path tempDir) throws Exception { Files.createDirectories(tempDir.resolve("a")); Files.createDirectories(tempDir.resolve("b")); Files.writeString(tempDir.resolve("a/OrderEvent.java"), @@ -138,7 +138,7 @@ class MachineEnumCanonicalizerCrossPackageTest { "a.OrderState", "a.OrderEvent"); MachineEnumCanonicalizer.canonicalizeTransitions(List.of(transition), types, context); - assertThat(transition.getEvent().fullIdentifier()).isEqualTo("OrderEvent.PAY"); + assertThat(transition.getEvent().fullIdentifier()).isEqualTo("a.OrderEvent.PAY"); assertThat(transition.getSourceStates().get(0).fullIdentifier()).isEqualTo("a.OrderState.NEW"); } } diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/AmbiguousSimpleEnumLinkingTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/AmbiguousSimpleEnumLinkingTest.java index 03b1a70..a68e8b6 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/AmbiguousSimpleEnumLinkingTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/AmbiguousSimpleEnumLinkingTest.java @@ -227,7 +227,7 @@ class AmbiguousSimpleEnumLinkingTest { } @Test - void shouldNotRewriteAmbiguousImportStylePolyDuringTriggerCanonicalization(@TempDir Path tempDir) throws Exception { + void shouldCanonicalizeImportStylePolyWhenMachineEventTypeIsKnown(@TempDir Path tempDir) throws Exception { Files.createDirectories(tempDir.resolve("a")); Files.createDirectories(tempDir.resolve("b")); Files.writeString(tempDir.resolve("a/OrderEvent.java"), @@ -257,7 +257,7 @@ class AmbiguousSimpleEnumLinkingTest { TriggerPoint canonical = result.getMetadata().getCallChains().get(0).getTriggerPoint(); assertThat(canonical.getPolymorphicEvents()) - .containsExactly("OrderEvent.PAY", "OrderEvent.SHIP"); + .containsExactly("a.OrderEvent.PAY", "a.OrderEvent.SHIP"); } } diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/AnalysisResultFinalizerTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/AnalysisResultFinalizerTest.java index 558a7a3..d90a1b4 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/AnalysisResultFinalizerTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/AnalysisResultFinalizerTest.java @@ -124,7 +124,7 @@ class AnalysisResultFinalizerTest { } @Test - void shouldPreserveAmbiguousImportStyleMatchedTransitions(@TempDir Path tempDir) throws Exception { + void shouldCanonicalizeAmbiguousImportStyleMatchedTransitions(@TempDir Path tempDir) throws Exception { Files.createDirectories(tempDir.resolve("a")); Files.createDirectories(tempDir.resolve("b")); Files.createDirectories(tempDir.resolve("com/example/config")); @@ -165,7 +165,7 @@ class AnalysisResultFinalizerTest { "a.OrderState", "a.OrderEvent")); assertThat(result.getMetadata().getCallChains().get(0).getMatchedTransitions().get(0).getEvent()) - .isEqualTo("OrderEvent.PAY"); + .isEqualTo("a.OrderEvent.PAY"); } private static void writeSampleConfig(Path tempDir) throws Exception { diff --git a/state_machine_exporter/src/test/resources/golden/PolymorphicStateMachineConfiguration/PolymorphicStateMachineConfiguration.json b/state_machine_exporter/src/test/resources/golden/PolymorphicStateMachineConfiguration/PolymorphicStateMachineConfiguration.json index 36c871f..73420c7 100644 --- a/state_machine_exporter/src/test/resources/golden/PolymorphicStateMachineConfiguration/PolymorphicStateMachineConfiguration.json +++ b/state_machine_exporter/src/test/resources/golden/PolymorphicStateMachineConfiguration/PolymorphicStateMachineConfiguration.json @@ -738,8 +738,16 @@ "ambiguous" : true }, "contextMachineId" : null, - "matchedTransitions" : null, - "linkResolution" : "AMBIGUOUS_WIDEN" + "matchedTransitions" : [ { + "sourceState" : "click.kamil.examples.statemachine.polymorphic.OrderStates.SUBMITTED", + "targetState" : "click.kamil.examples.statemachine.polymorphic.OrderStates.PAID", + "event" : "click.kamil.examples.statemachine.polymorphic.OrderEvents.PAY" + }, { + "sourceState" : "click.kamil.examples.statemachine.polymorphic.OrderStates.PAID", + "targetState" : "click.kamil.examples.statemachine.polymorphic.OrderStates.CANCELED", + "event" : "click.kamil.examples.statemachine.polymorphic.OrderEvents.ABCD" + } ], + "linkResolution" : "RESOLVED" } ], "properties" : { "default" : {