A
This commit is contained in:
@@ -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("<SYMBOLIC:")) {
|
||||
continue;
|
||||
}
|
||||
if (event.startsWith("ENUM_SET:")) {
|
||||
continue;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean matchesConfiguredTransitionEvent(
|
||||
TriggerPoint trigger,
|
||||
List<Transition> 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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
* <p>
|
||||
* 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 <em>concrete</em> machine enum
|
||||
* types at the sendEvent site and no vertical domain ownership in the call chain classes/packages.
|
||||
* Unbound generic parameters ({@code StateMachine<S,E>} 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<S,E>} 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<String> visited) {
|
||||
ITypeBinding current = binding;
|
||||
while (current != null) {
|
||||
|
||||
@@ -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<OrderState, OrderEvent> {}
|
||||
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<DocumentState, DocumentEvent> {}
|
||||
enum DocumentEvent { SUBMIT }
|
||||
enum DocumentState { DRAFT }
|
||||
""");
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.scan(tempDir);
|
||||
|
||||
// sendEvent lives on SharedService.updateState with StateMachine<S,E> — 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"), """
|
||||
|
||||
Reference in New Issue
Block a user