This commit is contained in:
2026-07-16 21:30:57 +02:00
parent 08adbcb5ff
commit 961873d29c
4 changed files with 198 additions and 21 deletions

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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) {

View File

@@ -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"), """