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 8a52e2b..e75e083 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 @@ -87,8 +87,8 @@ public class HeuristicBeanResolutionEngine implements BeanResolutionEngine { // Precise FQN Type argument match from JDT bindings at sendEvent site if (!hasExplicitBeanTarget && chain.getTriggerPoint() != null && context != null && currentMachineName != null) { - String triggerEventFqn = chain.getTriggerPoint().getEventTypeFqn(); - String triggerStateFqn = chain.getTriggerPoint().getStateTypeFqn(); + String triggerEventFqn = concreteTriggerType(chain.getTriggerPoint().getEventTypeFqn()); + String triggerStateFqn = concreteTriggerType(chain.getTriggerPoint().getStateTypeFqn()); if (triggerEventFqn != null || triggerStateFqn != null) { String[] machineTypes = StateMachineTypeResolver.resolve(currentMachineName, context); String machineStateFqn = machineTypes[0]; @@ -134,6 +134,8 @@ public class HeuristicBeanResolutionEngine implements BeanResolutionEngine { return false; } } + // Non-null-but-ambiguous type vars ("E"/"S") are ignored above — fall through so + // polymorphicEvents / shared-infrastructure / package evidence can decide. } if (!isSingleStateMachine(context) && isAmbiguousSharedGenericTrigger(chain.getTriggerPoint())) { @@ -142,7 +144,12 @@ public class HeuristicBeanResolutionEngine implements BeanResolutionEngine { if (injectionMatch != null) { return injectionMatch; } - return false; + // Ambiguous generics without injection evidence: do not hard-fail here when the trigger + // still has concrete polymorphicEvents — callers with transition lists use + // hasProvenMachineAffinity + SharedServiceRoutingPolicy for that case. + if (!hasConcretePolymorphicEvents(chain.getTriggerPoint())) { + return false; + } } Boolean packageMatch = PackageNameRoutingHeuristics.matches(chain, currentMachineName, explicitTarget); @@ -154,8 +161,8 @@ public class HeuristicBeanResolutionEngine implements BeanResolutionEngine { private DistinctTypeAffinity resolveDistinctTypeAffinity( TriggerPoint trigger, String machineName, CodebaseContext context) { - String triggerEventFqn = trigger.getEventTypeFqn(); - String triggerStateFqn = trigger.getStateTypeFqn(); + String triggerEventFqn = concreteTriggerType(trigger.getEventTypeFqn()); + String triggerStateFqn = concreteTriggerType(trigger.getStateTypeFqn()); if (triggerEventFqn == null && triggerStateFqn == null) { return DistinctTypeAffinity.UNKNOWN; } @@ -193,6 +200,27 @@ public class HeuristicBeanResolutionEngine implements BeanResolutionEngine { return DistinctTypeAffinity.UNKNOWN; } + /** Concrete machine types only — unbound {@code E}/{@code S} and erasure placeholders → null. */ + private static String concreteTriggerType(String typeFqn) { + return SharedServiceRoutingPolicy.isConcreteMachineType(typeFqn) ? typeFqn : null; + } + + private static boolean hasConcretePolymorphicEvents(TriggerPoint trigger) { + if (trigger == null || trigger.getPolymorphicEvents() == null) { + return false; + } + for (String event : trigger.getPolymorphicEvents()) { + if (event == null || event.isBlank() || event.startsWith(" machineTransitions, @@ -226,7 +254,20 @@ public class HeuristicBeanResolutionEngine implements BeanResolutionEngine { } private boolean isAmbiguousSharedGenericType(String typeFqn) { - return isErasedOrOpaqueType(typeFqn) || isStringType(typeFqn); + if (typeFqn == null || typeFqn.isBlank()) { + return false; + } + return !SharedServiceRoutingPolicy.isConcreteMachineType(typeFqn); + } + + private boolean isErasedOrOpaqueType(String typeFqn) { + if (typeFqn == null) { + return false; + } + String erased = eraseGenerics(typeFqn); + return "java.lang.Object".equals(erased) || "Object".equals(erased) + || "java.io.Serializable".equals(erased) || "Serializable".equals(erased) + || (erased.length() == 1 && Character.isUpperCase(erased.charAt(0))); } private boolean isDistinctTypeMismatched(String type1, String type2) { @@ -326,15 +367,6 @@ public class HeuristicBeanResolutionEngine implements BeanResolutionEngine { return !type1.equals(type2); } - private boolean isErasedOrOpaqueType(String typeFqn) { - if (typeFqn == null) { - return false; - } - String erased = eraseGenerics(typeFqn); - return "java.lang.Object".equals(erased) || "Object".equals(erased) - || "java.io.Serializable".equals(erased) || "Serializable".equals(erased); - } - private boolean isSingleStateMachine(CodebaseContext context) { return countStateMachines(context) <= 1; } 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 index 7edd0e1..2108c33 100644 --- 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 @@ -6,9 +6,11 @@ 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. + * Shared infrastructure requires source-derived evidence only: no concrete machine enum + * types at the sendEvent site and no vertical domain ownership in the call chain classes/packages. + * Unbound generic parameters ({@code StateMachine} on a shared base class) and erasure + * ({@code Object}) are treated as absent — not as proven types. Event-name matching alone is never + * sufficient outside this policy. */ final class SharedServiceRoutingPolicy { @@ -23,9 +25,30 @@ final class SharedServiceRoutingPolicy { return false; } MachineRoutingEvidence evidence = MachineRoutingEvidence.from(chain); - if (evidence.eventTypeFqn() != null || evidence.stateTypeFqn() != null) { + if (isConcreteMachineType(evidence.eventTypeFqn()) || isConcreteMachineType(evidence.stateTypeFqn())) { return false; } return !PackageNameRoutingHeuristics.hasVerticalDomainOwnership(chain); } + + /** + * True when a trigger type FQN is a real state/event enum (or similar), not an unbound + * type variable / erasure placeholder from a shared generic base class. + */ + static boolean isConcreteMachineType(String typeFqn) { + if (typeFqn == null || typeFqn.isBlank()) { + return false; + } + String erased = typeFqn; + int generic = erased.indexOf('<'); + if (generic >= 0) { + erased = erased.substring(0, generic); + } + if (erased.length() == 1 && Character.isUpperCase(erased.charAt(0))) { + return false; + } + return !"java.lang.Object".equals(erased) && !"Object".equals(erased) + && !"java.io.Serializable".equals(erased) && !"Serializable".equals(erased) + && !"java.lang.String".equals(erased) && !"String".equals(erased); + } } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/GenericEventDetector.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/GenericEventDetector.java index fecb93a..92442e1 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/GenericEventDetector.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/GenericEventDetector.java @@ -150,7 +150,9 @@ public class GenericEventDetector { if (smBinding != null) { ITypeBinding[] typeArgs = smBinding.getTypeArguments(); if (typeArgs.length >= 2) { - return new String[]{typeArgs[0].getQualifiedName(), typeArgs[1].getQualifiedName()}; + return new String[]{ + concreteStateMachineTypeArgFqn(typeArgs[0]), + concreteStateMachineTypeArgFqn(typeArgs[1])}; } } } @@ -162,6 +164,12 @@ public class GenericEventDetector { CompilationUnit compilationUnit = (CompilationUnit) node.getRoot(); String stateType = typeResolver.resolveTypeToFqn((Type) typeArgs.get(0), compilationUnit); String eventType = typeResolver.resolveTypeToFqn((Type) typeArgs.get(1), compilationUnit); + if (isUnresolvedTypeVariableName(stateType)) { + stateType = null; + } + if (isUnresolvedTypeVariableName(eventType)) { + eventType = null; + } return new String[]{stateType, eventType}; } } @@ -907,7 +915,9 @@ public class GenericEventDetector { if (smBinding != null) { ITypeBinding[] typeArgs = smBinding.getTypeArguments(); if (typeArgs.length >= 2) { - return new String[]{typeArgs[0].getQualifiedName(), typeArgs[1].getQualifiedName()}; + return new String[]{ + concreteStateMachineTypeArgFqn(typeArgs[0]), + concreteStateMachineTypeArgFqn(typeArgs[1])}; } } } @@ -927,6 +937,13 @@ public class GenericEventDetector { if (eventType != null && eventType.length() == 1) { eventType = resolveGenericTypeVariable(eventType, node, cu); } + // Unresolved class type variables (S/E) are not concrete machine types. + if (isUnresolvedTypeVariableName(stateType)) { + stateType = null; + } + if (isUnresolvedTypeVariableName(eventType)) { + eventType = null; + } return new String[]{stateType, eventType}; } @@ -975,6 +992,33 @@ public class GenericEventDetector { return typeVarName; } + /** + * Binding-derived {@code StateMachine} type arguments that are still unbound type variables + * (or wildcards) are not concrete machine enum types — leave them absent so routing can use + * {@code polymorphicEvents} / shared-infrastructure evidence instead. + */ + private static String concreteStateMachineTypeArgFqn(ITypeBinding typeArg) { + if (typeArg == null) { + return null; + } + if (typeArg.isTypeVariable() || typeArg.isCapture() || typeArg.isWildcardType()) { + return null; + } + String qualified = typeArg.getQualifiedName(); + if (isUnresolvedTypeVariableName(qualified)) { + return null; + } + return qualified; + } + + private static boolean isUnresolvedTypeVariableName(String typeName) { + if (typeName == null || typeName.isBlank()) { + return false; + } + // JDT often reports unbound parameters as a single uppercase letter ("S", "E"). + return typeName.length() == 1 && Character.isUpperCase(typeName.charAt(0)); + } + private ITypeBinding findStateMachineSupertype(ITypeBinding binding, java.util.Set visited) { ITypeBinding current = binding; while (current != null) { 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 index 23cbdb8..078879c 100644 --- 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 @@ -57,6 +57,84 @@ class SharedServiceRoutingPolicyTest { assertThat(SharedServiceRoutingPolicy.isProvablySharedInfrastructure(chain)).isFalse(); } + @Test + void unboundGenericTypeVariablesAreTreatedAsAbsentForSharedInfrastructure() { + CallChain chain = CallChain.builder() + .triggerPoint(TriggerPoint.builder() + .event("com.example.OrderEvent.PAY") + .polymorphicEvents(List.of("com.example.OrderEvent.PAY")) + .eventTypeFqn("E") + .stateTypeFqn("S") + .className("com.example.SharedService") + .methodName("updateState") + .build()) + .methodChain(List.of( + "com.example.CommonController.post()", + "com.example.SharedService.updateState()")) + .build(); + + assertThat(SharedServiceRoutingPolicy.isProvablySharedInfrastructure(chain)).isTrue(); + } + + @Test + void sharedGenericBaseWithTypeVariablesRoutesViaPolymorphicEvents(@TempDir Path tempDir) throws Exception { + Files.writeString(tempDir.resolve("OrderConfig.java"), """ + package com.example.order; + @org.springframework.statemachine.config.EnableStateMachine + public class OrderStateMachineConfig + extends org.springframework.statemachine.config.StateMachineConfigurerAdapter {} + enum OrderEvent { PAY } + enum OrderState { NEW, PAID } + """); + Files.writeString(tempDir.resolve("DocumentConfig.java"), """ + package com.example.document; + @org.springframework.statemachine.config.EnableStateMachine + public class DocumentStateMachineConfig + extends org.springframework.statemachine.config.StateMachineConfigurerAdapter {} + enum DocumentEvent { SUBMIT } + enum DocumentState { DRAFT } + """); + + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + + // sendEvent lives on SharedService.updateState with StateMachine — type args are + // unbound variables, but polymorphicEvents already resolved the concrete constant. + CallChain chain = CallChain.builder() + .triggerPoint(TriggerPoint.builder() + .event("com.example.order.OrderEvent.PAY") + .polymorphicEvents(List.of("com.example.order.OrderEvent.PAY")) + .eventTypeFqn("E") + .stateTypeFqn("S") + .className("com.example.SharedService") + .methodName("updateState") + .build()) + .methodChain(List.of( + "com.example.CommonController.pay()", + "com.example.SharedService.updateState()")) + .build(); + + 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", "com.example.order.OrderEvent.PAY")); + + Transition submit = new Transition(); + submit.setSourceStates(List.of(State.of("DRAFT", "DRAFT"))); + submit.setTargetStates(List.of(State.of("SUBMITTED", "SUBMITTED"))); + submit.setEvent(Event.of("SUBMIT", "com.example.document.DocumentEvent.SUBMIT")); + + assertThat(SharedServiceRoutingPolicy.isProvablySharedInfrastructure(chain)).isTrue(); + assertThat(engine.hasProvenMachineAffinity( + chain, "com.example.order.OrderStateMachineConfig", context, List.of(pay))) + .as("poly PAY must prove affinity to the order machine despite eventTypeFqn=E") + .isTrue(); + assertThat(engine.hasProvenMachineAffinity( + chain, "com.example.document.DocumentStateMachineConfig", context, List.of(submit))) + .as("PAY must not prove affinity to an unrelated document machine") + .isFalse(); + } + @Test void sharedInfrastructureMayMultiAttachOnSharedEventInMultiMachineContext(@TempDir Path tempDir) throws Exception { Files.writeString(tempDir.resolve("OrderConfig.java"), """