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:
2026-07-14 19:44:38 +02:00
parent 6ca8e64750
commit 33ccc4a6c5
5 changed files with 129 additions and 14 deletions

View File

@@ -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) {
List<String> concrete = concretePolymorphicCandidates(poly, eventTypeFqn, context);
if (concrete.size() <= 1) {
return false;
}
for (String pe : poly) {
if (pe == null || pe.startsWith("<SYMBOLIC:") || pe.startsWith("ENUM_SET:") || !pe.contains(".")) {
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;
}

View File

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

View File

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

View File

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

View File

@@ -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" : { }