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 27a26f5..ce87f97 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 @@ -471,6 +471,65 @@ public final class MachineEnumCanonicalizer { /** * Derives {@code EnumFormat.fn} display form ({@code OrderEvent.PAY}) from a package-canonical identifier. */ + public static String expectedFnRawName(String canonicalFqn, String enumTypeFqn) { + return toFnForm(canonicalFqn, enumTypeFqn); + } + + /** + * Returns whether {@code rawName} is the fn-form counterpart of a package-canonical {@code fullIdentifier}. + */ + public static boolean isRawNameConsistentWithFullIdentifier( + String rawName, + String fullIdentifier, + String enumTypeFqn) { + if (fullIdentifier == null || fullIdentifier.isBlank() || isStringOrPrimitiveType(enumTypeFqn)) { + return true; + } + if (fullIdentifier.startsWith("<") || fullIdentifier.startsWith("\"")) { + return true; + } + if (isDynamicTriggerExpression(fullIdentifier)) { + return true; + } + String canonical = canonicalizeLabel(fullIdentifier, enumTypeFqn); + if (!canonical.equals(fullIdentifier)) { + return true; + } + if (!constantName(fullIdentifier).matches("[A-Z_][A-Z0-9_]*")) { + return true; + } + String expectedRaw = toFnForm(fullIdentifier, enumTypeFqn); + if (expectedRaw == null) { + return true; + } + return expectedRaw.equals(rawName); + } + + /** + * Polymorphic event lists must contain only package-canonical enum constants or symbolic wildcards. + */ + public static boolean isValidPolymorphicEventForm(String event) { + if (event == null || event.isBlank()) { + return false; + } + TriggerEventKind kind = classifyTriggerEvent(event); + if (kind != TriggerEventKind.CANONICAL_ENUM && kind != TriggerEventKind.SYMBOLIC) { + return false; + } + return !hasCorruptedPackageDynamicEvent(event); + } + + /** + * True when every entry is a concrete package-canonical enum constant (not symbolic/dynamic). + */ + public static boolean hasOnlyConcretePolymorphicEvents(List polymorphicEvents) { + if (polymorphicEvents == null || polymorphicEvents.isEmpty()) { + return false; + } + return polymorphicEvents.stream() + .allMatch(pe -> classifyTriggerEvent(pe) == TriggerEventKind.CANONICAL_ENUM); + } + static String toFnForm(String canonicalFqn, String enumTypeFqn) { if (canonicalFqn == null || canonicalFqn.isBlank() || isStringOrPrimitiveType(enumTypeFqn)) { return null; diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/validation/AnalysisCanonicalFormValidator.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/validation/AnalysisCanonicalFormValidator.java index fd58498..82f8133 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/validation/AnalysisCanonicalFormValidator.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/validation/AnalysisCanonicalFormValidator.java @@ -2,6 +2,7 @@ package click.kamil.springstatemachineexporter.analysis.validation; import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult; import click.kamil.springstatemachineexporter.analysis.model.CallChain; +import click.kamil.springstatemachineexporter.analysis.model.LifecycleTriggerMarkers; import click.kamil.springstatemachineexporter.analysis.model.MatchedTransition; import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer; @@ -91,12 +92,14 @@ public final class AnalysisCanonicalFormValidator { List violations) { validateTransitions(result.getTransitions(), machineTypes, violations); validateStateCollection(result.getStates(), machineTypes.stateTypeFqn(), "states", violations); + validateStateRawNameSync(result.getStates(), machineTypes.stateTypeFqn(), "states", violations); validateStateLabels(result.getStartStates(), machineTypes.stateTypeFqn(), "startStates", violations); validateStateLabels(result.getEndStates(), machineTypes.stateTypeFqn(), "endStates", violations); if (result.getMetadata() != null) { validateTriggers(result.getMetadata().getTriggers(), machineTypes, violations); - validateCallChains(result.getMetadata().getCallChains(), machineTypes, violations); + validateCallChains( + result.getMetadata().getCallChains(), result.getTransitions(), machineTypes, violations); } } @@ -124,13 +127,21 @@ public final class AnalysisCanonicalFormValidator { transition.getEvent().fullIdentifier(), machineTypes.eventTypeFqn(), violations); + requireRawNameSync( + prefix + ".event.rawName", + transition.getEvent().rawName(), + transition.getEvent().fullIdentifier(), + machineTypes.eventTypeFqn(), + violations); } - validateStateList(transition.getSourceStates(), machineTypes.stateTypeFqn(), prefix + ".sourceStates", violations); - validateStateList(transition.getTargetStates(), machineTypes.stateTypeFqn(), prefix + ".targetStates", violations); + validateStateListWithRawSync( + transition.getSourceStates(), machineTypes.stateTypeFqn(), prefix + ".sourceStates", violations); + validateStateListWithRawSync( + transition.getTargetStates(), machineTypes.stateTypeFqn(), prefix + ".targetStates", violations); } } - private static void validateStateList( + private static void validateStateListWithRawSync( List states, String stateTypeFqn, String pathPrefix, @@ -139,7 +150,32 @@ public final class AnalysisCanonicalFormValidator { return; } for (int i = 0; i < states.size(); i++) { - requireCanonical(pathPrefix + "[" + i + "].fullIdentifier", states.get(i).fullIdentifier(), stateTypeFqn, violations); + State state = states.get(i); + requireCanonical(pathPrefix + "[" + i + "].fullIdentifier", state.fullIdentifier(), stateTypeFqn, violations); + requireRawNameSync( + pathPrefix + "[" + i + "].rawName", + state.rawName(), + state.fullIdentifier(), + stateTypeFqn, + violations); + } + } + private static void validateStateRawNameSync( + Set states, + String stateTypeFqn, + String pathPrefix, + List violations) { + if (states == null) { + return; + } + int i = 0; + for (State state : states) { + requireRawNameSync( + pathPrefix + "[" + i++ + "].rawName", + state.rawName(), + state.fullIdentifier(), + stateTypeFqn, + violations); } } @@ -187,14 +223,14 @@ public final class AnalysisCanonicalFormValidator { requireCanonical(prefix + ".event", trigger.getEvent(), eventTypeFqn, violations); validateTriggerEventForm(prefix + ".event", trigger.getEvent(), violations); requireCanonical(prefix + ".sourceState", trigger.getSourceState(), stateTypeFqn, violations); + validateDynamicIdentifierForm(prefix + ".sourceState", trigger.getSourceState(), violations); if (trigger.getPolymorphicEvents() != null) { for (int j = 0; j < trigger.getPolymorphicEvents().size(); j++) { - requireCanonical( - prefix + ".polymorphicEvents[" + j + "]", - trigger.getPolymorphicEvents().get(j), - eventTypeFqn, - violations); + String polyPath = prefix + ".polymorphicEvents[" + j + "]"; + String polyEvent = trigger.getPolymorphicEvents().get(j); + requireCanonical(polyPath, polyEvent, eventTypeFqn, violations); + validatePolymorphicEventForm(polyPath, polyEvent, violations); } } } @@ -202,6 +238,7 @@ public final class AnalysisCanonicalFormValidator { private static void validateCallChains( List callChains, + List transitions, StateMachineTypeResolver.MachineTypes machineTypes, List violations) { if (callChains == null) { @@ -218,15 +255,17 @@ public final class AnalysisCanonicalFormValidator { requireCanonical(triggerPrefix + ".event", trigger.getEvent(), eventTypeFqn, violations); validateTriggerEventForm(triggerPrefix + ".event", trigger.getEvent(), violations); requireCanonical(triggerPrefix + ".sourceState", trigger.getSourceState(), stateTypeFqn, violations); + validateDynamicIdentifierForm(triggerPrefix + ".sourceState", trigger.getSourceState(), violations); if (trigger.getPolymorphicEvents() != null) { for (int j = 0; j < trigger.getPolymorphicEvents().size(); j++) { - requireCanonical( - triggerPrefix + ".polymorphicEvents[" + j + "]", - trigger.getPolymorphicEvents().get(j), - eventTypeFqn, - violations); + String polyPath = triggerPrefix + ".polymorphicEvents[" + j + "]"; + String polyEvent = trigger.getPolymorphicEvents().get(j); + requireCanonical(polyPath, polyEvent, eventTypeFqn, violations); + validatePolymorphicEventForm(polyPath, polyEvent, violations); } } + validateMatchedTransitionsWhenResolvable( + prefix, chain, trigger, transitions, machineTypes.eventTypeFqn(), violations); } if (chain.getMatchedTransitions() != null) { for (int j = 0; j < chain.getMatchedTransitions().size(); j++) { @@ -261,6 +300,124 @@ public final class AnalysisCanonicalFormValidator { } } + private static void requireRawNameSync( + String path, + String rawName, + String fullIdentifier, + String enumTypeFqn, + List violations) { + if (MachineEnumCanonicalizer.isRawNameConsistentWithFullIdentifier(rawName, fullIdentifier, enumTypeFqn)) { + return; + } + String expected = MachineEnumCanonicalizer.expectedFnRawName(fullIdentifier, enumTypeFqn); + violations.add(new Violation(path, rawName, expected)); + } + + private static void validatePolymorphicEventForm(String path, String event, List violations) { + if (event == null || event.isBlank()) { + return; + } + if (!MachineEnumCanonicalizer.isValidPolymorphicEventForm(event)) { + MachineEnumCanonicalizer.TriggerEventKind kind = MachineEnumCanonicalizer.classifyTriggerEvent(event); + String expected = kind == MachineEnumCanonicalizer.TriggerEventKind.DYNAMIC_EXPRESSION + && MachineEnumCanonicalizer.hasCorruptedPackageDynamicEvent(event) + ? MachineEnumCanonicalizer.denormalizeCorruptedDynamicEvent(event) + : "CANONICAL_ENUM or SYMBOLIC"; + violations.add(new Violation(path, event, expected)); + } + } + + private static void validateDynamicIdentifierForm(String path, String value, List violations) { + if (value == null || value.isBlank()) { + return; + } + if (MachineEnumCanonicalizer.hasCorruptedPackageDynamicEvent(value)) { + violations.add(new Violation( + path, + value, + MachineEnumCanonicalizer.denormalizeCorruptedDynamicEvent(value))); + } + } + + private static void validateMatchedTransitionsWhenResolvable( + String chainPrefix, + CallChain chain, + TriggerPoint trigger, + List transitions, + String eventTypeFqn, + List violations) { + if (trigger.isExternal() || trigger.isAmbiguous()) { + return; + } + if (LifecycleTriggerMarkers.isLifecycle(trigger.getEvent())) { + return; + } + if (!MachineEnumCanonicalizer.hasOnlyConcretePolymorphicEvents(trigger.getPolymorphicEvents())) { + return; + } + if (MachineEnumCanonicalizer.classifyTriggerEvent(trigger.getEvent()) + != MachineEnumCanonicalizer.TriggerEventKind.DYNAMIC_EXPRESSION) { + return; + } + if (chain.getMatchedTransitions() != null && !chain.getMatchedTransitions().isEmpty()) { + return; + } + if (!polymorphicEventsMatchAnyTransition(trigger.getPolymorphicEvents(), transitions, eventTypeFqn)) { + return; + } + violations.add(new Violation( + chainPrefix + ".matchedTransitions", + "empty", + "non-empty when dynamic trigger has concrete polymorphicEvents matching transitions")); + } + + private static boolean polymorphicEventsMatchAnyTransition( + List polymorphicEvents, + List transitions, + String eventTypeFqn) { + if (transitions == null || transitions.isEmpty()) { + return false; + } + for (String polyEvent : polymorphicEvents) { + for (Transition transition : transitions) { + if (transition.getEvent() == null) { + continue; + } + String smEvent = transition.getEvent().fullIdentifier() != null + ? transition.getEvent().fullIdentifier() + : transition.getEvent().rawName(); + if (eventsMatch(polyEvent, smEvent, eventTypeFqn)) { + return true; + } + } + } + return false; + } + + private static boolean eventsMatch(String triggerEvent, String smEvent, String eventTypeFqn) { + if (triggerEvent == null || smEvent == null) { + return false; + } + if (triggerEvent.equals(smEvent)) { + return true; + } + String triggerConst = constantName(triggerEvent); + String smConst = constantName(smEvent); + if (!triggerConst.equals(smConst)) { + return false; + } + if (MachineEnumCanonicalizer.isMachineEnumReference(triggerEvent, eventTypeFqn) + && MachineEnumCanonicalizer.isMachineEnumReference(smEvent, eventTypeFqn)) { + return true; + } + return false; + } + + private static String constantName(String ref) { + int dot = ref.lastIndexOf('.'); + return dot >= 0 ? ref.substring(dot + 1) : ref; + } + private static void validateTriggerEventForm(String path, String event, List violations) { if (event == null || event.isBlank()) { return; 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 30ba795..3b77e3c 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,28 @@ class MachineEnumCanonicalizerTest { "com.example.order.OrderEvent.PAY"); } + @Test + void shouldValidateRawNameConsistencyForCanonicalHelpers() { + assertThat(MachineEnumCanonicalizer.isRawNameConsistentWithFullIdentifier( + "OrderEvent.PAY", "com.example.order.OrderEvent.PAY", "com.example.order.OrderEvent")) + .isTrue(); + assertThat(MachineEnumCanonicalizer.isRawNameConsistentWithFullIdentifier( + "UNDEFINED", "com.example.order.OrderEvent.PAY", "com.example.order.OrderEvent")) + .isFalse(); + assertThat(MachineEnumCanonicalizer.isValidPolymorphicEventForm("com.example.order.OrderEvent.PAY")) + .isTrue(); + assertThat(MachineEnumCanonicalizer.isValidPolymorphicEventForm("")) + .isTrue(); + assertThat(MachineEnumCanonicalizer.isValidPolymorphicEventForm("OrderEvent.valueOf(event)")) + .isFalse(); + assertThat(MachineEnumCanonicalizer.hasOnlyConcretePolymorphicEvents( + List.of("com.example.order.OrderEvent.PAY"))) + .isTrue(); + assertThat(MachineEnumCanonicalizer.hasOnlyConcretePolymorphicEvents( + List.of(""))) + .isFalse(); + } + @Test void shouldDedupeStatesByCanonicalFullIdentifier(@TempDir Path tempDir) throws IOException { writeSampleConfig(tempDir); diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/ExportPipelineIdentifierConsistencyTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/ExportPipelineIdentifierConsistencyTest.java index 0d9e614..671e200 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/ExportPipelineIdentifierConsistencyTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/ExportPipelineIdentifierConsistencyTest.java @@ -82,9 +82,83 @@ class ExportPipelineIdentifierConsistencyTest { .resolve("com.example.config.OrderStateMachineConfiguration.json")); assertNoMixedEnumIdentifiers(exported); + assertRawNameSync(exported); assertThat(exported.getMetadata().getCallChains().get(0).getMatchedTransitions()).isNotEmpty(); } + static void assertRawNameSync(AnalysisResult result) { + String stateTypeFqn = result.getStateTypeFqn(); + String eventTypeFqn = result.getEventTypeFqn(); + if (stateTypeFqn == null || !stateTypeFqn.contains(".") + || MachineEnumCanonicalizer.isStringOrPrimitiveType(stateTypeFqn)) { + return; + } + if (result.getStates() != null) { + for (State state : result.getStates()) { + assertThat(MachineEnumCanonicalizer.isRawNameConsistentWithFullIdentifier( + state.rawName(), state.fullIdentifier(), stateTypeFqn)) + .as("state rawName sync for %s", state.fullIdentifier()) + .isTrue(); + } + } + if (result.getTransitions() != null) { + for (Transition transition : result.getTransitions()) { + if (transition.getEvent() != null) { + assertThat(MachineEnumCanonicalizer.isRawNameConsistentWithFullIdentifier( + transition.getEvent().rawName(), + transition.getEvent().fullIdentifier(), + eventTypeFqn)) + .as("event rawName sync for %s", transition.getEvent().fullIdentifier()) + .isTrue(); + } + assertStateListRawSync(transition.getSourceStates(), stateTypeFqn); + assertStateListRawSync(transition.getTargetStates(), stateTypeFqn); + } + } + } + + private static void assertStateListRawSync(List states, String stateTypeFqn) { + if (states == null) { + return; + } + for (State state : states) { + assertThat(MachineEnumCanonicalizer.isRawNameConsistentWithFullIdentifier( + state.rawName(), state.fullIdentifier(), stateTypeFqn)) + .as("state rawName sync for %s", state.fullIdentifier()) + .isTrue(); + } + } + + private static void assertPolymorphicEventForms(AnalysisResult result) { + if (result.getMetadata() == null) { + return; + } + collectTriggers(result).forEach(trigger -> { + if (trigger.getPolymorphicEvents() != null) { + for (String poly : trigger.getPolymorphicEvents()) { + assertThat(MachineEnumCanonicalizer.isValidPolymorphicEventForm(poly)) + .as("polymorphic event form: %s", poly) + .isTrue(); + } + } + }); + } + + private static List collectTriggers(AnalysisResult result) { + List triggers = new java.util.ArrayList<>(); + if (result.getMetadata().getTriggers() != null) { + triggers.addAll(result.getMetadata().getTriggers()); + } + if (result.getMetadata().getCallChains() != null) { + for (CallChain chain : result.getMetadata().getCallChains()) { + if (chain.getTriggerPoint() != null) { + triggers.add(chain.getTriggerPoint()); + } + } + } + return triggers; + } + @Test void astExportShouldNotMixShortAndPackageCanonicalStateIdentifiers(@TempDir Path tempDir) throws Exception { Path enterprise = findProjectRoot().resolve("state_machines/state_machine_enterprise"); @@ -105,7 +179,9 @@ class ExportPipelineIdentifierConsistencyTest { } AnalysisResult result = new JsonImportService().importAnalysisResult(json); assertNoMixedEnumIdentifiers(result); + assertRawNameSync(result); assertTriggerEventForms(result); + assertPolymorphicEventForms(result); } } } diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/validation/AnalysisCanonicalFormValidatorTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/validation/AnalysisCanonicalFormValidatorTest.java index 8fa06fe..189e862 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/validation/AnalysisCanonicalFormValidatorTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/validation/AnalysisCanonicalFormValidatorTest.java @@ -171,6 +171,151 @@ class AnalysisCanonicalFormValidatorTest { .noneMatch(v -> v.path().contains(".event")); } + @Test + void shouldFailWhenEventRawNameDoesNotMatchFnForm() { + Transition transition = new Transition(); + transition.setEvent(Event.of("UNDEFINED", "com.example.order.OrderEvent.PAY")); + transition.setSourceStates(List.of( + State.of("OrderState.NEW", "com.example.order.OrderState.NEW"))); + + AnalysisResult result = AnalysisResult.builder() + .name("com.example.config.OrderStateMachineConfiguration") + .stateTypeFqn("com.example.order.OrderState") + .eventTypeFqn("com.example.order.OrderEvent") + .transitions(List.of(transition)) + .build(); + + List violations = + AnalysisCanonicalFormValidator.validateWithMachineTypes( + result, + new click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver.MachineTypes( + "com.example.order.OrderState", "com.example.order.OrderEvent")); + + assertThat(violations) + .extracting(AnalysisCanonicalFormValidator.Violation::path) + .contains("transitions[0].event.rawName"); + } + + @Test + void shouldFailWhenPolymorphicEventIsDynamicExpression() { + AnalysisResult result = AnalysisResult.builder() + .name("com.example.config.OrderStateMachineConfiguration") + .stateTypeFqn("com.example.order.OrderState") + .eventTypeFqn("com.example.order.OrderEvent") + .metadata(CodebaseMetadata.builder() + .triggers(List.of(TriggerPoint.builder() + .event("OrderEvent.valueOf(eventString)") + .className("com.example.web.OrderController") + .methodName("dispatch") + .sourceFile("OrderController.java") + .polymorphicEvents(List.of("OrderEvent.valueOf(eventString)")) + .build())) + .build()) + .build(); + + List violations = + AnalysisCanonicalFormValidator.validateWithMachineTypes( + result, + new click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver.MachineTypes( + "com.example.order.OrderState", "com.example.order.OrderEvent")); + + assertThat(violations) + .extracting(AnalysisCanonicalFormValidator.Violation::path) + .contains("metadata.triggers[0].polymorphicEvents[0]"); + } + + @Test + void shouldFailWhenSourceStateHasCorruptedPackageValueOfForm() { + AnalysisResult result = AnalysisResult.builder() + .name("com.example.config.OrderStateMachineConfiguration") + .stateTypeFqn("com.example.order.OrderState") + .eventTypeFqn("com.example.order.OrderEvent") + .metadata(CodebaseMetadata.builder() + .triggers(List.of(TriggerPoint.builder() + .event("com.example.order.OrderEvent.PAY") + .sourceState("com.example.order.OrderState.valueOf(stateString)") + .className("com.example.web.OrderController") + .methodName("dispatch") + .sourceFile("OrderController.java") + .build())) + .build()) + .build(); + + List violations = + AnalysisCanonicalFormValidator.validateWithMachineTypes( + result, + new click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver.MachineTypes( + "com.example.order.OrderState", "com.example.order.OrderEvent")); + + assertThat(violations) + .extracting(AnalysisCanonicalFormValidator.Violation::path) + .contains("metadata.triggers[0].sourceState"); + } + + @Test + void shouldFailWhenDynamicTriggerHasConcretePolymorphicEventsButNoMatchedTransitions() { + AnalysisResult result = AnalysisResult.builder() + .name("com.example.config.OrderStateMachineConfiguration") + .stateTypeFqn("com.example.order.OrderState") + .eventTypeFqn("com.example.order.OrderEvent") + .transitions(List.of(transition( + "com.example.order.OrderEvent.PAY", + "com.example.order.OrderState.NEW", + "com.example.order.OrderState.PAID"))) + .metadata(CodebaseMetadata.builder() + .callChains(List.of(CallChain.builder() + .triggerPoint(TriggerPoint.builder() + .event("event.getType()") + .className("com.example.web.OrderController") + .methodName("dispatch") + .sourceFile("OrderController.java") + .polymorphicEvents(List.of("com.example.order.OrderEvent.PAY")) + .build()) + .build())) + .build()) + .build(); + + List violations = + AnalysisCanonicalFormValidator.validateWithMachineTypes( + result, + new click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver.MachineTypes( + "com.example.order.OrderState", "com.example.order.OrderEvent")); + + assertThat(violations) + .extracting(AnalysisCanonicalFormValidator.Violation::path) + .contains("metadata.callChains[0].matchedTransitions"); + } + + @Test + void shouldAllowSymbolicPolymorphicEventsWithoutMatchedTransitions() { + AnalysisResult result = AnalysisResult.builder() + .name("com.example.config.OrderStateMachineConfiguration") + .stateTypeFqn("com.example.order.OrderState") + .eventTypeFqn("com.example.order.OrderEvent") + .transitions(List.of(transition( + "com.example.order.OrderEvent.PAY", + "com.example.order.OrderState.NEW", + "com.example.order.OrderState.PAID"))) + .metadata(CodebaseMetadata.builder() + .callChains(List.of(CallChain.builder() + .triggerPoint(TriggerPoint.builder() + .event("event.getType()") + .className("com.example.web.OrderController") + .methodName("dispatch") + .sourceFile("OrderController.java") + .polymorphicEvents(List.of("")) + .build()) + .build())) + .build()) + .build(); + + assertThat(AnalysisCanonicalFormValidator.validateWithMachineTypes( + result, + new click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver.MachineTypes( + "com.example.order.OrderState", "com.example.order.OrderEvent"))) + .noneMatch(v -> v.path().contains("matchedTransitions")); + } + @Test void shouldSkipValidationForStringStateMachines(@TempDir Path tempDir) throws IOException { Path configPkg = tempDir.resolve("com/example/config"); @@ -195,13 +340,25 @@ class AnalysisCanonicalFormValidatorTest { } private static Transition transition(String eventFqn, String sourceFqn, String targetFqn) { + String eventRaw = fnForm(eventFqn); + String sourceRaw = fnForm(sourceFqn); + String targetRaw = fnForm(targetFqn); Transition transition = new Transition(); - transition.setEvent(Event.of(eventFqn, eventFqn)); - transition.setSourceStates(List.of(State.of(sourceFqn, sourceFqn))); - transition.setTargetStates(List.of(State.of(targetFqn, targetFqn))); + transition.setEvent(Event.of(eventRaw, eventFqn)); + transition.setSourceStates(List.of(State.of(sourceRaw, sourceFqn))); + transition.setTargetStates(List.of(State.of(targetRaw, targetFqn))); return transition; } + private static String fnForm(String identifier) { + if (identifier == null || !identifier.contains(".")) { + return identifier; + } + int lastDot = identifier.lastIndexOf('.'); + int prevDot = identifier.lastIndexOf('.', lastDot - 1); + return prevDot > 0 ? identifier.substring(prevDot + 1) : identifier; + } + private static void writeSampleConfig(Path tempDir) throws IOException { Path orderPkg = tempDir.resolve("com/example/order"); Path configPkg = tempDir.resolve("com/example/config");