diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/routing/HeuristicBeanResolutionEngine.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/routing/HeuristicBeanResolutionEngine.java index c03bbfd..c4f4d22 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/routing/HeuristicBeanResolutionEngine.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/routing/HeuristicBeanResolutionEngine.java @@ -62,11 +62,13 @@ public class HeuristicBeanResolutionEngine implements BeanResolutionEngine { } TriggerPoint trigger = chain.getTriggerPoint(); - if (trigger != null && matchesConfiguredTransitionEvent(trigger, machineTransitions)) { + if (trigger != null + && SharedServiceRoutingPolicy.isProvablySharedInfrastructure(chain) + && matchesConfiguredTransitionEvent(trigger, machineTransitions)) { return true; } - return false; + return isRoutedToCorrectMachine(chain, currentMachineName, context); } @Override diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/routing/PackageNameRoutingHeuristics.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/routing/PackageNameRoutingHeuristics.java index c2d67e6..75a5ef9 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/routing/PackageNameRoutingHeuristics.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/routing/PackageNameRoutingHeuristics.java @@ -9,9 +9,44 @@ import java.util.Set; */ final class PackageNameRoutingHeuristics { + private static final Set GENERIC_CLASS_PREFIXES = Set.of( + "state", "statemachine", "config", "configuration", "adapter", + "enterprise", "app", "global", "core", "project", "main", + "base", "common", "shared"); + + private static final Set GENERIC_PACKAGE_SEGMENTS = Set.of( + "config", "configuration", "service", "services", "impl", "api", "web", + "controller", "controllers", "messaging", "listener", "listeners", + "repository", "repositories", "db", "model", "models", "domain", + "constants", "utils", "helper", "helpers", "event", "events", + "state", "states", "transition", "transitions", "shared", "common", + "global", "core", "base", "main", "app", "enterprise"); + private PackageNameRoutingHeuristics() { } + /** + * @return true when any class in the call chain carries a vertical domain prefix or package segment + */ + static boolean hasVerticalDomainOwnership(CallChain chain) { + if (chain == null) { + return false; + } + if (chain.getTriggerPoint() != null && chain.getTriggerPoint().getClassName() != null) { + if (classHasVerticalDomainOwnership(chain.getTriggerPoint().getClassName())) { + return true; + } + } + if (chain.getMethodChain() != null) { + for (String method : chain.getMethodChain()) { + if (classHasVerticalDomainOwnership(getClassNameOnly(method))) { + return true; + } + } + } + return false; + } + /** * @return true/false if package heuristics decide, null if inconclusive */ @@ -60,7 +95,7 @@ final class PackageNameRoutingHeuristics { String chainSimple = getSimpleClassName(chainClass); String chainPrefix = getFirstCamelCaseWord(chainSimple); - if (smPrefix != null && chainPrefix != null && smPrefix.equals(chainPrefix)) { + if (hasMatchingDomainPrefix(smPrefix, chainPrefix)) { hasPositiveMatch = true; } @@ -75,7 +110,7 @@ final class PackageNameRoutingHeuristics { String chainSimple = getSimpleClassName(chainClass); String chainPrefix = getFirstCamelCaseWord(chainSimple); - if (smPrefix != null && chainPrefix != null && smPrefix.equals(chainPrefix)) { + if (hasMatchingDomainPrefix(smPrefix, chainPrefix)) { hasPositiveMatch = true; } @@ -151,16 +186,7 @@ final class PackageNameRoutingHeuristics { String smLower = smPrefix.toLowerCase(); String chainLower = chainPrefix.toLowerCase(); - if (smLower.equals("state") || smLower.equals("statemachine") || smLower.equals("config") - || smLower.equals("configuration") || smLower.equals("adapter") - || smLower.equals("enterprise") || smLower.equals("app") || smLower.equals("global") - || smLower.equals("core") || smLower.equals("project") || smLower.equals("main") - || smLower.equals("base") || smLower.equals("common") || smLower.equals("shared") - || chainLower.equals("state") || chainLower.equals("statemachine") || chainLower.equals("config") - || chainLower.equals("configuration") || chainLower.equals("adapter") - || chainLower.equals("enterprise") || chainLower.equals("app") || chainLower.equals("global") - || chainLower.equals("core") || chainLower.equals("project") || chainLower.equals("main") - || chainLower.equals("base") || chainLower.equals("common") || chainLower.equals("shared")) { + if (isGenericClassPrefix(smLower) || isGenericClassPrefix(chainLower)) { return false; } @@ -181,22 +207,15 @@ final class PackageNameRoutingHeuristics { } if (matchIdx >= 1) { boolean onlyGenericDivergence = true; - Set genericLayers = Set.of( - "config", "configuration", "service", "services", "impl", "api", "web", - "controller", "controllers", "messaging", "listener", "listeners", - "repository", "repositories", "db", "model", "models", "domain", - "constants", "utils", "helper", "helpers", "event", "events", - "state", "states", "transition", "transitions" - ); for (int i = matchIdx + 1; i < p1.length; i++) { - if (!genericLayers.contains(p1[i].toLowerCase())) { + if (!isGenericPackageSegment(p1[i])) { onlyGenericDivergence = false; break; } } if (onlyGenericDivergence) { for (int i = matchIdx + 1; i < p2.length; i++) { - if (!genericLayers.contains(p2[i].toLowerCase())) { + if (!isGenericPackageSegment(p2[i])) { onlyGenericDivergence = false; break; } @@ -253,4 +272,40 @@ final class PackageNameRoutingHeuristics { } return null; } + + private static boolean classHasVerticalDomainOwnership(String classFqn) { + if (classFqn == null || classFqn.isEmpty()) { + return false; + } + String prefix = getFirstCamelCaseWord(getSimpleClassName(classFqn)); + if (prefix != null && !isGenericClassPrefix(prefix)) { + return true; + } + return hasVerticalPackageSegment(getPackageName(classFqn)); + } + + private static boolean hasVerticalPackageSegment(String packageName) { + if (packageName == null || packageName.isEmpty()) { + return false; + } + String[] segments = packageName.split("\\."); + for (int i = 2; i < segments.length; i++) { + if (!isGenericPackageSegment(segments[i])) { + return true; + } + } + return false; + } + + private static boolean hasMatchingDomainPrefix(String smPrefix, String chainPrefix) { + return smPrefix != null && chainPrefix != null && smPrefix.equals(chainPrefix); + } + + private static boolean isGenericClassPrefix(String prefix) { + return prefix != null && GENERIC_CLASS_PREFIXES.contains(prefix.toLowerCase()); + } + + private static boolean isGenericPackageSegment(String segment) { + return segment != null && GENERIC_PACKAGE_SEGMENTS.contains(segment.toLowerCase()); + } } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/routing/SharedServiceRoutingPolicy.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/routing/SharedServiceRoutingPolicy.java new file mode 100644 index 0000000..7edd0e1 --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/routing/SharedServiceRoutingPolicy.java @@ -0,0 +1,31 @@ +package click.kamil.springstatemachineexporter.analysis.enricher.routing; + +import click.kamil.springstatemachineexporter.analysis.model.CallChain; + +/** + * Decides whether a call chain is provably shared infrastructure that may intentionally + * attach to multiple state machines when they share the same transition event. + *

+ * Shared infrastructure requires source-derived evidence only: no distinct machine enum types + * at the sendEvent site and no vertical domain ownership in the call chain classes/packages. + * Event-name matching alone is never sufficient outside this policy. + */ +final class SharedServiceRoutingPolicy { + + private SharedServiceRoutingPolicy() { + } + + /** + * @return true when the chain may multi-attach to every machine that shares its transition event + */ + static boolean isProvablySharedInfrastructure(CallChain chain) { + if (chain == null) { + return false; + } + MachineRoutingEvidence evidence = MachineRoutingEvidence.from(chain); + if (evidence.eventTypeFqn() != null || evidence.stateTypeFqn() != null) { + return false; + } + return !PackageNameRoutingHeuristics.hasVerticalDomainOwnership(chain); + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/routing/SharedServiceRoutingPolicyTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/routing/SharedServiceRoutingPolicyTest.java new file mode 100644 index 0000000..23cbdb8 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/routing/SharedServiceRoutingPolicyTest.java @@ -0,0 +1,187 @@ +package click.kamil.springstatemachineexporter.analysis.enricher.routing; + +import click.kamil.springstatemachineexporter.analysis.model.CallChain; +import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import click.kamil.springstatemachineexporter.model.Event; +import click.kamil.springstatemachineexporter.model.State; +import click.kamil.springstatemachineexporter.model.Transition; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class SharedServiceRoutingPolicyTest { + + private final HeuristicBeanResolutionEngine engine = new HeuristicBeanResolutionEngine(); + + @Test + void commonOrderServiceChainIsProvablySharedInfrastructure() { + CallChain chain = CallChain.builder() + .triggerPoint(TriggerPoint.builder().event("PROCESS").build()) + .methodChain(List.of( + "com.example.CommonOrderController.post()", + "com.example.CommonOrderService.processOrderEvent()")) + .build(); + + assertThat(SharedServiceRoutingPolicy.isProvablySharedInfrastructure(chain)).isTrue(); + } + + @Test + void electronicsVerticalChainIsNotProvablySharedInfrastructure() { + CallChain chain = CallChain.builder() + .triggerPoint(TriggerPoint.builder().event("ASSEMBLE").build()) + .methodChain(List.of( + "com.example.electronics.ElectronicsOrderController.post()", + "com.example.electronics.ElectronicsOrderService.processOrderEvent()")) + .build(); + + assertThat(SharedServiceRoutingPolicy.isProvablySharedInfrastructure(chain)).isFalse(); + } + + @Test + void chainWithProvenTypeFqnsIsNotProvablySharedInfrastructure() { + CallChain chain = CallChain.builder() + .triggerPoint(TriggerPoint.builder() + .event("com.example.OrderEvent.PAY") + .eventTypeFqn("com.example.OrderEvent") + .stateTypeFqn("com.example.OrderState") + .build()) + .methodChain(List.of("com.example.SharedDispatcher.payOrder()")) + .build(); + + assertThat(SharedServiceRoutingPolicy.isProvablySharedInfrastructure(chain)).isFalse(); + } + + @Test + void sharedInfrastructureMayMultiAttachOnSharedEventInMultiMachineContext(@TempDir Path tempDir) throws Exception { + Files.writeString(tempDir.resolve("OrderConfig.java"), """ + package com.example; + @org.springframework.statemachine.config.EnableStateMachine + public class OrderStateMachineConfig + extends org.springframework.statemachine.config.StateMachineConfigurerAdapter {} + """); + Files.writeString(tempDir.resolve("DocumentConfig.java"), """ + package com.example; + @org.springframework.statemachine.config.EnableStateMachine + public class DocumentStateMachineConfig + extends org.springframework.statemachine.config.StateMachineConfigurerAdapter {} + """); + + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + + CallChain chain = CallChain.builder() + .triggerPoint(TriggerPoint.builder().event("PROCESS").build()) + .methodChain(List.of( + "com.example.CommonOrderController.post()", + "com.example.CommonOrderService.processOrderEvent()")) + .build(); + + Transition transition = new Transition(); + transition.setSourceStates(List.of(State.of("NEW", "NEW"))); + transition.setTargetStates(List.of(State.of("PROCESSED", "PROCESSED"))); + transition.setEvent(Event.of("PROCESS", "PROCESS")); + List machineTransitions = List.of(transition); + + assertThat(engine.hasProvenMachineAffinity( + chain, "com.example.OrderStateMachineConfig", context, machineTransitions)).isTrue(); + assertThat(engine.hasProvenMachineAffinity( + chain, "com.example.DocumentStateMachineConfig", context, machineTransitions)).isTrue(); + } + + @Test + void verticalChainFailsClosedOnEventMatchInMultiMachineContext(@TempDir Path tempDir) throws Exception { + Files.writeString(tempDir.resolve("ElectronicsConfig.java"), """ + package com.example.electronics; + @org.springframework.statemachine.config.EnableStateMachine + public class ElectronicsStateMachineConfig + extends org.springframework.statemachine.config.StateMachineConfigurerAdapter {} + """); + Files.writeString(tempDir.resolve("ComputerConfig.java"), """ + package com.example.computerstore; + @org.springframework.statemachine.config.EnableStateMachine + public class ComputerStateMachineConfig + extends org.springframework.statemachine.config.StateMachineConfigurerAdapter {} + """); + + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + + CallChain chain = CallChain.builder() + .triggerPoint(TriggerPoint.builder().event("ASSEMBLE").build()) + .methodChain(List.of( + "com.example.electronics.ElectronicsOrderController.post()", + "com.example.electronics.ElectronicsOrderService.processOrderEvent()")) + .build(); + + Transition transition = new Transition(); + transition.setSourceStates(List.of(State.of("NEW", "NEW"))); + transition.setTargetStates(List.of(State.of("ASSEMBLED", "ASSEMBLED"))); + transition.setEvent(Event.of("ASSEMBLE", "ASSEMBLE")); + List machineTransitions = List.of(transition); + + assertThat(engine.hasProvenMachineAffinity( + chain, "com.example.electronics.ElectronicsStateMachineConfig", context, machineTransitions)) + .isTrue(); + assertThat(engine.hasProvenMachineAffinity( + chain, "com.example.computerstore.ComputerStateMachineConfig", context, machineTransitions)) + .isFalse(); + } + + @Test + void provenDistinctTypesStillRouteToSingleMachine(@TempDir Path tempDir) throws Exception { + Files.writeString(tempDir.resolve("SharedDispatcher.java"), """ + package com.example; + public class SharedDispatcher { + void payOrder() { + org.springframework.statemachine.StateMachine machine = null; + machine.sendEvent(OrderEvent.PAY); + } + void submitDocument() { + org.springframework.statemachine.StateMachine machine = null; + machine.sendEvent(DocumentEvent.SUBMIT); + } + } + enum OrderEvent { PAY } + enum OrderState { NEW, PAID } + enum DocumentEvent { SUBMIT } + enum DocumentState { DRAFT } + class OrderStateMachineConfiguration + extends org.springframework.statemachine.config.StateMachineConfigurerAdapter {} + class DocumentStateMachineConfiguration + extends org.springframework.statemachine.config.StateMachineConfigurerAdapter {} + """); + + CodebaseContext context = new CodebaseContext(); + context.setResolveBindings(true); + context.scan(tempDir); + + CallChain orderChain = CallChain.builder() + .triggerPoint(TriggerPoint.builder() + .event("com.example.OrderEvent.PAY") + .eventTypeFqn("com.example.OrderEvent") + .stateTypeFqn("com.example.OrderState") + .className("com.example.SharedDispatcher") + .methodName("payOrder") + .build()) + .methodChain(List.of("com.example.SharedDispatcher.payOrder()")) + .build(); + + Transition payTransition = new Transition(); + payTransition.setSourceStates(List.of(State.of("NEW", "NEW"))); + payTransition.setTargetStates(List.of(State.of("PAID", "PAID"))); + payTransition.setEvent(Event.of("PAY", "PAY")); + + assertThat(engine.hasProvenMachineAffinity( + orderChain, "com.example.OrderStateMachineConfiguration", context, List.of(payTransition))) + .isTrue(); + assertThat(engine.hasProvenMachineAffinity( + orderChain, "com.example.DocumentStateMachineConfiguration", context, List.of(payTransition))) + .isFalse(); + } +}