diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizer.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizer.java index 0ec6e0c..ac5c887 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizer.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizer.java @@ -125,6 +125,9 @@ public final class MachineEnumCanonicalizer { } if (pe.startsWith("")) { String symbolicType = pe.substring(" 1) { isAmbiguous = true; } + if (polymorphicEvents.isEmpty() + && context.isAmbiguousSimpleName(declaredType) + && !tp.isExternal() + && !isRuntimeEnumParameter(exprNode instanceof Expression expression ? expression : null) + && !(keyedMapLookupOnInitializer[0] && pathBoundMapKeyOnInitializer[0])) { + String symbolic = ""; + if (!polymorphicEvents.contains(symbolic)) { + polymorphicEvents.add(symbolic); + } + isAmbiguous = true; + } } } } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/CodebaseContext.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/CodebaseContext.java index 6271b04..3edc9fd 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/CodebaseContext.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/CodebaseContext.java @@ -513,6 +513,17 @@ public class CodebaseContext { return enumValues; } + /** + * Returns true when multiple types share the same simple name across packages, so simple-name + * lookup must fail closed. + */ + public boolean isAmbiguousSimpleName(String name) { + if (name == null || name.isBlank() || name.contains(".")) { + return false; + } + return ambiguousSimpleNames.contains(name); + } + public List getEnumValues(String fqnOrSimpleName) { if (fqnOrSimpleName == null) return null; diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizerTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizerTest.java index 8175998..1c884ff 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizerTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizerTest.java @@ -211,6 +211,26 @@ class MachineEnumCanonicalizerTest { "com.example.order.OrderEvent.PAY"); } + @Test + void shouldNotExpandSymbolicPlaceholderWhenSimpleEnumNameIsAmbiguous(@TempDir Path tempDir) throws IOException { + Files.createDirectories(tempDir.resolve("a")); + Files.createDirectories(tempDir.resolve("b")); + Files.writeString(tempDir.resolve("a/OrderEvent.java"), + "package a; public enum OrderEvent { PAY, SHIP }"); + Files.writeString(tempDir.resolve("b/OrderEvent.java"), + "package b; public enum OrderEvent { CANCEL }"); + + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + + List expanded = MachineEnumCanonicalizer.expandSymbolicPolymorphicEvents( + List.of(""), + "a.OrderEvent", + context); + + assertThat(expanded).isEmpty(); + } + @Test void shouldValidateRawNameConsistencyForCanonicalHelpers() { assertThat(MachineEnumCanonicalizer.isRawNameConsistentWithFullIdentifier( diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/AmbiguousSimpleEnumLinkingTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/AmbiguousSimpleEnumLinkingTest.java index 5800b09..3077a85 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/AmbiguousSimpleEnumLinkingTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/AmbiguousSimpleEnumLinkingTest.java @@ -157,5 +157,72 @@ class AmbiguousSimpleEnumLinkingTest { assertThat(linked.getMatchedTransitions()).isNullOrEmpty(); assertThat(linked.getLinkResolution()).isIn(LinkResolution.NO_MATCH, LinkResolution.AMBIGUOUS_WIDEN); } + + @Test + void shouldFailClosedWhenMachineEventTypeWouldExpandAmbiguousSymbolic(@TempDir Path tempDir) throws Exception { + Files.createDirectories(tempDir.resolve("a")); + Files.createDirectories(tempDir.resolve("b")); + Files.writeString(tempDir.resolve("a/OrderEvent.java"), + "package a; public enum OrderEvent { PAY, SHIP }"); + Files.writeString(tempDir.resolve("b/OrderEvent.java"), + "package b; public enum OrderEvent { CANCEL }"); + Files.writeString(tempDir.resolve("App.java"), """ + package com.example; + import a.OrderEvent; + public class OrderController { + Dispatcher dispatcher = new Dispatcher(); + public void transition(String eventStr) { + dispatcher.dispatch(eventStr); + } + } + class Dispatcher { + void dispatch(String eventStr) { + StateMachine sm = new StateMachine(); + sm.sendEvent(OrderEvent.valueOf(eventStr)); + } + } + class StateMachine { void sendEvent(OrderEvent e) {} } + """); + + CodebaseContext context = new CodebaseContext(); + context.setResolveBindings(false); + context.scan(tempDir); + + HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context); + EntryPoint entry = EntryPoint.builder() + .className("com.example.OrderController") + .methodName("transition") + .build(); + TriggerPoint trigger = TriggerPoint.builder() + .className("com.example.StateMachine") + .methodName("sendEvent") + .event("e") + .build(); + + CallChain chain = engine.findChains(List.of(entry), List.of(trigger)).get(0); + + Transition pay = new Transition(); + pay.setSourceStates(List.of(State.of("NEW", "NEW"))); + pay.setTargetStates(List.of(State.of("PAID", "PAID"))); + pay.setEvent(Event.of("PAY", "a.OrderEvent.PAY")); + + Transition ship = new Transition(); + ship.setSourceStates(List.of(State.of("PAID", "PAID"))); + ship.setTargetStates(List.of(State.of("SHIPPED", "SHIPPED"))); + ship.setEvent(Event.of("SHIP", "a.OrderEvent.SHIP")); + + AnalysisResult result = AnalysisResult.builder() + .name("com.example.OrderStateMachineConfig") + .eventTypeFqn("a.OrderEvent") + .transitions(List.of(pay, ship)) + .metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build()) + .build(); + + new TransitionLinkerEnricher().enrich(result, context, null); + + CallChain linked = result.getMetadata().getCallChains().get(0); + assertThat(linked.getMatchedTransitions()).isNullOrEmpty(); + assertThat(linked.getLinkResolution()).isIn(LinkResolution.NO_MATCH, LinkResolution.AMBIGUOUS_WIDEN); + } } diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/ast/common/CodebaseContextTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/ast/common/CodebaseContextTest.java index 322f432..863040a 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/ast/common/CodebaseContextTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/ast/common/CodebaseContextTest.java @@ -248,6 +248,8 @@ class CodebaseContextTest { .isNull(); assertThat(context.getEnumValues("a.OrderEvent")).containsExactly("a.OrderEvent.PAY"); assertThat(context.getEnumValues("b.OrderEvent")).containsExactly("b.OrderEvent.SHIP"); + assertThat(context.isAmbiguousSimpleName("OrderEvent")).isTrue(); + assertThat(context.isAmbiguousSimpleName("a.OrderEvent")).isFalse(); } @Test