From 33ccc4a6c58d94070cc69cbce71c4922c3f36329 Mon Sep 17 00:00:00 2001 From: Kamil Patryk Kozakowski Date: Tue, 14 Jul 2026 19:44:38 +0200 Subject: [PATCH] Link transitions when the same event maps to multiple sources in one state enum. Stop treating multi-source same-event triggers as ambiguous widen failures; trust bare polymorphic constant names when the machine event type is known. Co-authored-by: Cursor --- .../enricher/CallChainLinkPolicy.java | 43 ++++++++++++++++--- .../enricher/TransitionLinkerEnricher.java | 21 ++++++++- .../enricher/CallChainLinkPolicyTest.java | 14 ++++++ .../TransitionLinkerEnricherTest.java | 37 ++++++++++++++++ .../StateMachineConfig.json | 28 +++++++++--- 5 files changed, 129 insertions(+), 14 deletions(-) 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 d378d8d..d4bfbb6 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 @@ -9,6 +9,7 @@ import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanon import click.kamil.springstatemachineexporter.analysis.service.ExternalTriggerPolicy; import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import java.util.ArrayList; import java.util.List; /** @@ -81,13 +82,11 @@ public final class CallChainLinkPolicy { return false; } List poly = trigger.getPolymorphicEvents(); - if (poly == null || poly.size() <= 1) { + List concrete = concretePolymorphicCandidates(poly, eventTypeFqn, context); + if (concrete.size() <= 1) { return false; } - for (String pe : poly) { - if (pe == null || pe.startsWith(" concretePolymorphicCandidates( + List poly, + String eventTypeFqn, + CodebaseContext context) { + if (poly == null || poly.isEmpty()) { + return List.of(); + } + List concrete = new ArrayList<>(); + for (String pe : poly) { + if (pe == null || pe.startsWith(" matched, @@ -119,13 +146,15 @@ public final class CallChainLinkPolicy { if (trigger != null && trigger.isExternal()) { return LinkResolution.UNRESOLVED_EXTERNAL; } - if (ambiguousSource - || shouldFailClosedOnAmbiguousCallGraphWiden(trigger, machineEventTypeFqn, context)) { + if (shouldFailClosedOnAmbiguousCallGraphWiden(trigger, machineEventTypeFqn, context)) { return LinkResolution.AMBIGUOUS_WIDEN; } if (matched != null && !matched.isEmpty()) { return LinkResolution.RESOLVED; } + if (ambiguousSource) { + return LinkResolution.AMBIGUOUS_WIDEN; + } return LinkResolution.NO_MATCH; } 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 26f5f4f..f05aaa8 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 @@ -96,6 +96,7 @@ public class TransitionLinkerEnricher implements AnalysisEnricher { if (triggerSource == null) { Map> sourcesByEvent = new LinkedHashMap<>(); + Map> stateTypesByEvent = new LinkedHashMap<>(); Map canonicalSourceBySimplified = new LinkedHashMap<>(); for (Transition t : stateMachineTransitions) { if (t.getEvent() != null && matchingEngine.matches(t.getEvent(), tp) @@ -109,12 +110,19 @@ public class TransitionLinkerEnricher implements AnalysisEnricher { String smSource = simplifySourceState(smSourceForLink); sourcesByEvent.computeIfAbsent(smEventForLink, ignored -> new LinkedHashSet<>()) .add(smSource); + stateTypesByEvent.computeIfAbsent(smEventForLink, ignored -> new LinkedHashSet<>()) + .add(extractStateEnumType(smSourceForLink)); canonicalSourceBySimplified.putIfAbsent(smSource, smSourceForLink); } } } - if (sourcesByEvent.values().stream().anyMatch(sources -> sources.size() > 1)) { + boolean crossStateTypeConflict = stateTypesByEvent.values().stream() + .anyMatch(types -> types.size() > 1); + if (crossStateTypeConflict) { ambiguousSource = true; + } else if (sourcesByEvent.values().stream().anyMatch(sources -> sources.size() > 1)) { + // Same event from multiple sources within one state enum is normal — link all. + ambiguousSource = false; } else { Set allDistinctSources = new LinkedHashSet<>(); sourcesByEvent.values().forEach(allDistinctSources::addAll); @@ -257,6 +265,17 @@ public class TransitionLinkerEnricher implements AnalysisEnricher { return stripped.trim(); } + private String extractStateEnumType(String stateIdentifier) { + if (stateIdentifier == null || stateIdentifier.isBlank()) { + return stateIdentifier; + } + int lastDot = stateIdentifier.lastIndexOf('.'); + if (lastDot <= 0) { + return stateIdentifier; + } + return stateIdentifier.substring(0, lastDot); + } + private final java.util.Map simplifySourceCache = new java.util.HashMap<>(); private String simplifySourceState(String name) { diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/CallChainLinkPolicyTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/CallChainLinkPolicyTest.java index dd58aab..8c3d40d 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/CallChainLinkPolicyTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/CallChainLinkPolicyTest.java @@ -136,4 +136,18 @@ class CallChainLinkPolicyTest { false, "com.example.order.OrderEvent")).isEqualTo(LinkResolution.RESOLVED); } + + @Test + void shouldTrustBareConstantPolymorphicCandidatesWhenMachineEventTypeIsKnown() { + TriggerPoint trigger = TriggerPoint.builder() + .ambiguous(true) + .event("OrderEvent.valueOf(eventStr)") + .polymorphicEvents(List.of("PAY", "SHIP")) + .build(); + + assertThat(CallChainLinkPolicy.isTrustedEnumPolymorphicWiden( + trigger, "com.example.order.OrderEvent")).isTrue(); + assertThat(CallChainLinkPolicy.shouldFailClosedOnAmbiguousCallGraphWiden( + trigger, "com.example.order.OrderEvent")).isFalse(); + } } diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/TransitionLinkerEnricherTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/TransitionLinkerEnricherTest.java index 74b6ac7..f8cf92e 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/TransitionLinkerEnricherTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/TransitionLinkerEnricherTest.java @@ -221,6 +221,43 @@ class TransitionLinkerEnricherTest { assertThat(updatedChain.getTriggerPoint().isAmbiguous()).isFalse(); } + @Test + void shouldLinkWhenSameEventAppearsFromMultipleSourcesWithinSameStateEnum() { + Transition payFromNew = new Transition(); + payFromNew.setSourceStates(List.of(State.of("NEW", "com.example.order.OrderState.NEW"))); + payFromNew.setTargetStates(List.of(State.of("PAID", "com.example.order.OrderState.PAID"))); + payFromNew.setEvent(Event.of("PAY", "com.example.order.OrderEvent.PAY")); + + Transition payFromPending = new Transition(); + payFromPending.setSourceStates(List.of(State.of("PENDING", "com.example.order.OrderState.PENDING"))); + payFromPending.setTargetStates(List.of(State.of("PAID", "com.example.order.OrderState.PAID"))); + payFromPending.setEvent(Event.of("PAY", "com.example.order.OrderEvent.PAY")); + + CallChain chain = CallChain.builder() + .triggerPoint(TriggerPoint.builder() + .ambiguous(true) + .event("OrderEvent.valueOf(eventStr)") + .polymorphicEvents(List.of( + "com.example.order.OrderEvent.PAY", + "com.example.order.OrderEvent.SHIP")) + .build()) + .build(); + + AnalysisResult result = AnalysisResult.builder() + .name("com.example.config.OrderStateMachineConfiguration") + .eventTypeFqn("com.example.order.OrderEvent") + .stateTypeFqn("com.example.order.OrderState") + .transitions(List.of(payFromNew, payFromPending)) + .metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build()) + .build(); + + enricher.enrich(result, null, null); + + CallChain updatedChain = result.getMetadata().getCallChains().get(0); + assertThat(updatedChain.getMatchedTransitions()).hasSize(2); + assertThat(updatedChain.getLinkResolution()).isEqualTo(LinkResolution.RESOLVED); + } + @Test void shouldNotInferSourceStateWhenDifferentEnumsShareSameConstantName() { Transition t1 = new Transition(); diff --git a/state_machine_exporter/src/test/resources/golden/StateMachineConfig/StateMachineConfig.json b/state_machine_exporter/src/test/resources/golden/StateMachineConfig/StateMachineConfig.json index 5847d8f..cd4d6c2 100644 --- a/state_machine_exporter/src/test/resources/golden/StateMachineConfig/StateMachineConfig.json +++ b/state_machine_exporter/src/test/resources/golden/StateMachineConfig/StateMachineConfig.json @@ -315,11 +315,19 @@ "polymorphicEvents" : [ "click.kamil.domain.OrderEvent.CANCEL" ], "external" : false, "constraint" : "event instanceof OrderEvent", - "ambiguous" : true + "ambiguous" : false }, "contextMachineId" : null, - "matchedTransitions" : null, - "linkResolution" : "AMBIGUOUS_WIDEN" + "matchedTransitions" : [ { + "sourceState" : "click.kamil.domain.OrderState.NEW", + "targetState" : "click.kamil.domain.OrderState.CANCELLED", + "event" : "click.kamil.domain.OrderEvent.CANCEL" + }, { + "sourceState" : "click.kamil.domain.OrderState.PROCESSING", + "targetState" : "click.kamil.domain.OrderState.CANCELLED", + "event" : "click.kamil.domain.OrderEvent.CANCEL" + } ], + "linkResolution" : "RESOLVED" }, { "entryPoint" : { "type" : "REST", @@ -463,11 +471,19 @@ "polymorphicEvents" : [ "click.kamil.domain.OrderEvent.CANCEL" ], "external" : false, "constraint" : "event != null", - "ambiguous" : true + "ambiguous" : false }, "contextMachineId" : null, - "matchedTransitions" : null, - "linkResolution" : "AMBIGUOUS_WIDEN" + "matchedTransitions" : [ { + "sourceState" : "click.kamil.domain.OrderState.NEW", + "targetState" : "click.kamil.domain.OrderState.CANCELLED", + "event" : "click.kamil.domain.OrderEvent.CANCEL" + }, { + "sourceState" : "click.kamil.domain.OrderState.PROCESSING", + "targetState" : "click.kamil.domain.OrderState.CANCELLED", + "event" : "click.kamil.domain.OrderEvent.CANCEL" + } ], + "linkResolution" : "RESOLVED" } ], "properties" : { "default" : { }