From bbfb54c51b40f6f71cb9e117ad834028ca318f94 Mon Sep 17 00:00:00 2001 From: Kamil Patryk Kozakowski Date: Mon, 13 Jul 2026 21:48:15 +0200 Subject: [PATCH] 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 --- .../enricher/TransitionLinkerEnricher.java | 36 ++++++++++--------- .../TransitionLinkerEnricherTest.java | 30 ++++++++++++++++ 2 files changed, 49 insertions(+), 17 deletions(-) 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 da6ad5b..953254f 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 @@ -65,7 +65,7 @@ public class TransitionLinkerEnricher implements AnalysisEnricher { continue; } - String triggerSource = tp.getSourceState() != null ? simplify(tp.getSourceState()) : null; + String triggerSource = tp.getSourceState() != null ? simplifySourceState(tp.getSourceState()) : null; if (CallChainLinkPolicy.shouldFailClosedOnAmbiguousCallGraphWiden(tp)) { updatedChains.add(chain.toBuilder() .triggerPoint(tp) @@ -87,7 +87,7 @@ public class TransitionLinkerEnricher implements AnalysisEnricher { String smEventForLink = canonicalEvent(t.getEvent()); for (State smSourceState : t.getSourceStates()) { String smSourceForLink = canonicalState(smSourceState); - String smSource = simplify(smSourceForLink); + String smSource = simplifySourceState(smSourceForLink); sourcesByEvent.computeIfAbsent(smEventForLink, ignored -> new LinkedHashSet<>()) .add(smSource); canonicalSourceBySimplified.putIfAbsent(smSource, smSourceForLink); @@ -116,7 +116,7 @@ public class TransitionLinkerEnricher implements AnalysisEnricher { String smEventForLink = canonicalEvent(t.getEvent()); for (State smSourceState : t.getSourceStates()) { String smSourceForLink = canonicalState(smSourceState); - String smSource = simplify(smSourceForLink); + String smSource = simplifySourceState(smSourceForLink); if (triggerSource == null || triggerSource.equals(smSource)) { if (t.getTargetStates() == null || t.getTargetStates().isEmpty()) { MatchedTransition mt = MatchedTransition.builder() @@ -201,23 +201,25 @@ public class TransitionLinkerEnricher implements AnalysisEnricher { constraint, MachineDomainKeys.extractMachineDomainKey(machineName)); } - private final java.util.Map simplifyCache = 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 final java.util.Map simplifySourceCache = new java.util.HashMap<>(); - private String simplify(String name) { + private String simplifySourceState(String name) { if (name == null) return null; - if (simplifyCache.containsKey(name)) return simplifyCache.get(name); - - // Strip common suffixes - String simplified = SUFFIX_PATTERN.matcher(name).replaceAll("$1"); - if (simplified.isEmpty()) { - simplified = name; + if (simplifySourceCache.containsKey(name)) return simplifySourceCache.get(name); + + // For source states we must preserve enum type context to avoid collisions between + // different state enums that share a constant name (e.g. OrderState.NEW vs InvoiceState.NEW). + // Use EnumFormat.fn-like suffix (Type.CONST) when possible. + 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; } 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 8e66a06..74b6ac7 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,36 @@ class TransitionLinkerEnricherTest { 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 void shouldLinkTransitionByEventAndSourceState() { Transition t1 = new Transition();