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 <cursoragent@cursor.com>
This commit is contained in:
@@ -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<String> poly = trigger.getPolymorphicEvents();
|
||||
if (poly == null || poly.size() <= 1) {
|
||||
return false;
|
||||
}
|
||||
for (String pe : poly) {
|
||||
if (pe == null || pe.startsWith("<SYMBOLIC:") || pe.startsWith("ENUM_SET:") || !pe.contains(".")) {
|
||||
List<String> concrete = concretePolymorphicCandidates(poly, eventTypeFqn, context);
|
||||
if (concrete.size() <= 1) {
|
||||
return false;
|
||||
}
|
||||
for (String pe : concrete) {
|
||||
if (!MachineEnumCanonicalizer.polymorphicEventMatchesMachineEventType(pe, eventTypeFqn, context)) {
|
||||
return false;
|
||||
}
|
||||
@@ -95,6 +94,34 @@ public final class CallChainLinkPolicy {
|
||||
return true;
|
||||
}
|
||||
|
||||
private static List<String> concretePolymorphicCandidates(
|
||||
List<String> poly,
|
||||
String eventTypeFqn,
|
||||
CodebaseContext context) {
|
||||
if (poly == null || poly.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<String> concrete = new ArrayList<>();
|
||||
for (String pe : poly) {
|
||||
if (pe == null || pe.startsWith("<SYMBOLIC:") || pe.startsWith("ENUM_SET:")) {
|
||||
continue;
|
||||
}
|
||||
String qualified = pe;
|
||||
if (!pe.contains(".")) {
|
||||
if (eventTypeFqn == null || eventTypeFqn.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
qualified = eventTypeFqn + "." + pe;
|
||||
} else if (eventTypeFqn != null && !eventTypeFqn.isBlank()) {
|
||||
qualified = MachineEnumCanonicalizer.canonicalizeLabel(pe, eventTypeFqn, context);
|
||||
}
|
||||
if (qualified != null && qualified.contains(".")) {
|
||||
concrete.add(qualified);
|
||||
}
|
||||
}
|
||||
return concrete;
|
||||
}
|
||||
|
||||
public static LinkResolution resolveLinkResolution(
|
||||
TriggerPoint trigger,
|
||||
List<MatchedTransition> 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -96,6 +96,7 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
|
||||
|
||||
if (triggerSource == null) {
|
||||
Map<String, Set<String>> sourcesByEvent = new LinkedHashMap<>();
|
||||
Map<String, Set<String>> stateTypesByEvent = new LinkedHashMap<>();
|
||||
Map<String, String> 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<String> 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<String, String> simplifySourceCache = new java.util.HashMap<>();
|
||||
|
||||
private String simplifySourceState(String name) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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" : { }
|
||||
|
||||
Reference in New Issue
Block a user