Avoid source-state inference collisions across enum types

When inferring missing trigger sourceState from the transition table, preserve enum type context (Type.CONST) so different state enums sharing a constant name do not collapse into a single inferred source.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-13 21:48:15 +02:00
parent 44497afd32
commit bbfb54c51b
2 changed files with 49 additions and 17 deletions

View File

@@ -65,7 +65,7 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
continue; continue;
} }
String triggerSource = tp.getSourceState() != null ? simplify(tp.getSourceState()) : null; String triggerSource = tp.getSourceState() != null ? simplifySourceState(tp.getSourceState()) : null;
if (CallChainLinkPolicy.shouldFailClosedOnAmbiguousCallGraphWiden(tp)) { if (CallChainLinkPolicy.shouldFailClosedOnAmbiguousCallGraphWiden(tp)) {
updatedChains.add(chain.toBuilder() updatedChains.add(chain.toBuilder()
.triggerPoint(tp) .triggerPoint(tp)
@@ -87,7 +87,7 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
String smEventForLink = canonicalEvent(t.getEvent()); String smEventForLink = canonicalEvent(t.getEvent());
for (State smSourceState : t.getSourceStates()) { for (State smSourceState : t.getSourceStates()) {
String smSourceForLink = canonicalState(smSourceState); String smSourceForLink = canonicalState(smSourceState);
String smSource = simplify(smSourceForLink); String smSource = simplifySourceState(smSourceForLink);
sourcesByEvent.computeIfAbsent(smEventForLink, ignored -> new LinkedHashSet<>()) sourcesByEvent.computeIfAbsent(smEventForLink, ignored -> new LinkedHashSet<>())
.add(smSource); .add(smSource);
canonicalSourceBySimplified.putIfAbsent(smSource, smSourceForLink); canonicalSourceBySimplified.putIfAbsent(smSource, smSourceForLink);
@@ -116,7 +116,7 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
String smEventForLink = canonicalEvent(t.getEvent()); String smEventForLink = canonicalEvent(t.getEvent());
for (State smSourceState : t.getSourceStates()) { for (State smSourceState : t.getSourceStates()) {
String smSourceForLink = canonicalState(smSourceState); String smSourceForLink = canonicalState(smSourceState);
String smSource = simplify(smSourceForLink); String smSource = simplifySourceState(smSourceForLink);
if (triggerSource == null || triggerSource.equals(smSource)) { if (triggerSource == null || triggerSource.equals(smSource)) {
if (t.getTargetStates() == null || t.getTargetStates().isEmpty()) { if (t.getTargetStates() == null || t.getTargetStates().isEmpty()) {
MatchedTransition mt = MatchedTransition.builder() MatchedTransition mt = MatchedTransition.builder()
@@ -201,23 +201,25 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
constraint, MachineDomainKeys.extractMachineDomainKey(machineName)); constraint, MachineDomainKeys.extractMachineDomainKey(machineName));
} }
private final java.util.Map<String, String> simplifyCache = new java.util.HashMap<>(); private final java.util.Map<String, String> simplifySourceCache = new java.util.HashMap<>();
private static final java.util.regex.Pattern SUFFIX_PATTERN = java.util.regex.Pattern.compile("(?i)(.+)(Event|Action|Transition|Command)(s)?$");
private static final java.util.regex.Pattern FQN_PATTERN = java.util.regex.Pattern.compile("^.*\\.([A-Z0-9_]+)$");
private String simplify(String name) { private String simplifySourceState(String name) {
if (name == null) return null; if (name == null) return null;
if (simplifyCache.containsKey(name)) return simplifyCache.get(name); if (simplifySourceCache.containsKey(name)) return simplifySourceCache.get(name);
// Strip common suffixes // For source states we must preserve enum type context to avoid collisions between
String simplified = SUFFIX_PATTERN.matcher(name).replaceAll("$1"); // different state enums that share a constant name (e.g. OrderState.NEW vs InvoiceState.NEW).
if (simplified.isEmpty()) { // Use EnumFormat.fn-like suffix (Type.CONST) when possible.
simplified = name; String simplified = name;
int lastDot = name.lastIndexOf('.');
if (lastDot > 0) {
int prevDot = name.lastIndexOf('.', lastDot - 1);
if (prevDot > 0) {
simplified = name.substring(prevDot + 1);
}
} }
// Simplify full identifiers to just the last part (enum name)
simplified = FQN_PATTERN.matcher(simplified).replaceAll("$1");
simplifyCache.put(name, simplified); simplifySourceCache.put(name, simplified);
return simplified; return simplified;
} }

View File

@@ -221,6 +221,36 @@ class TransitionLinkerEnricherTest {
assertThat(updatedChain.getTriggerPoint().isAmbiguous()).isFalse(); assertThat(updatedChain.getTriggerPoint().isAmbiguous()).isFalse();
} }
@Test
void shouldNotInferSourceStateWhenDifferentEnumsShareSameConstantName() {
Transition t1 = new Transition();
t1.setSourceStates(List.of(State.of("NEW", "com.example.order.OrderState.NEW")));
t1.setTargetStates(List.of(State.of("PAID", "com.example.order.OrderState.PAID")));
t1.setEvent(Event.of("PAY", "PAY"));
Transition t2 = new Transition();
t2.setSourceStates(List.of(State.of("NEW", "com.example.invoice.InvoiceState.NEW")));
t2.setTargetStates(List.of(State.of("PAID", "com.example.invoice.InvoiceState.PAID")));
t2.setEvent(Event.of("PAY", "PAY"));
CallChain chain = CallChain.builder()
.triggerPoint(TriggerPoint.builder().event("PAY").build())
.build();
AnalysisResult result = AnalysisResult.builder()
.transitions(List.of(t1, t2))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
.build();
enricher.enrich(result, null, null);
CallChain updatedChain = result.getMetadata().getCallChains().get(0);
assertThat(updatedChain.getTriggerPoint().getSourceState()).isNull();
assertThat(updatedChain.getTriggerPoint().isAmbiguous()).isTrue();
assertThat(updatedChain.getMatchedTransitions()).isNullOrEmpty();
assertThat(updatedChain.getLinkResolution()).isEqualTo(click.kamil.springstatemachineexporter.analysis.model.LinkResolution.AMBIGUOUS_WIDEN);
}
@Test @Test
void shouldLinkTransitionByEventAndSourceState() { void shouldLinkTransitionByEventAndSourceState() {
Transition t1 = new Transition(); Transition t1 = new Transition();