From 5972b9df50e3f495a8cf99038388a4189ce118ee Mon Sep 17 00:00:00 2001 From: Kamil Patryk Kozakowski Date: Tue, 14 Jul 2026 05:29:20 +0200 Subject: [PATCH] Fail closed on ambiguous symbolic enum expansion Block expandSymbolicPolymorphicEvents from widening when the simple type name is ambiguous across packages, and emit symbolic markers from the call graph when enum values cannot be resolved. Co-authored-by: Cursor --- .../resolver/MachineEnumCanonicalizer.java | 3 + .../service/AbstractCallGraphEngine.java | 11 +++ .../ast/common/CodebaseContext.java | 11 +++ .../MachineEnumCanonicalizerTest.java | 20 ++++++ .../AmbiguousSimpleEnumLinkingTest.java | 67 +++++++++++++++++++ .../ast/common/CodebaseContextTest.java | 2 + 6 files changed, 114 insertions(+) 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