Scope triggers by @Qualifier bean name and infer source state from switch expressions.

Extract stateMachineId from @Qualifier on sendEvent receivers, route named beans via @EnableStateMachine before String/String type widening, and extend sourceState detection to SwitchExpression arrow cases.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-13 18:20:58 +02:00
parent 169fae88ab
commit ad567ace0c
7 changed files with 340 additions and 1082 deletions

View File

@@ -2,7 +2,9 @@ package click.kamil.springstatemachineexporter.analysis.enricher.routing;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver;
import click.kamil.springstatemachineexporter.ast.common.AstUtils;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.Annotation;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import java.util.HashSet;
@@ -12,8 +14,21 @@ public class HeuristicBeanResolutionEngine implements BeanResolutionEngine {
@Override
public boolean isRoutedToCorrectMachine(CallChain chain, String currentMachineName, CodebaseContext context) {
String explicitTarget = chain.getContextMachineId();
if (explicitTarget == null && chain.getTriggerPoint() != null) {
explicitTarget = chain.getTriggerPoint().getStateMachineId();
}
if (explicitTarget != null && !explicitTarget.isEmpty() && context != null && currentMachineName != null) {
Boolean namedTarget = resolveNamedBeanTarget(explicitTarget, currentMachineName, context);
if (namedTarget != null) {
return namedTarget;
}
}
boolean hasExplicitBeanTarget = explicitTarget != null && !explicitTarget.isEmpty();
// Precise FQN Type argument match from JDT bindings at sendEvent site
if (chain.getTriggerPoint() != null && context != null && currentMachineName != null) {
if (!hasExplicitBeanTarget && chain.getTriggerPoint() != null && context != null && currentMachineName != null) {
String triggerEventFqn = chain.getTriggerPoint().getEventTypeFqn();
String triggerStateFqn = chain.getTriggerPoint().getStateTypeFqn();
if (triggerEventFqn != null || triggerStateFqn != null) {
@@ -54,7 +69,7 @@ public class HeuristicBeanResolutionEngine implements BeanResolutionEngine {
}
}
String targetVar = chain.getContextMachineId();
String targetVar = hasExplicitBeanTarget ? explicitTarget : chain.getContextMachineId();
if (targetVar == null && chain.getTriggerPoint() != null) {
targetVar = chain.getTriggerPoint().getStateMachineId();
}
@@ -129,6 +144,67 @@ public class HeuristicBeanResolutionEngine implements BeanResolutionEngine {
return context == null || isSingleStateMachine(context);
}
private Boolean resolveNamedBeanTarget(String beanName, String currentMachineName, CodebaseContext context) {
String normalized = stripAnnotationQuotes(beanName);
if (normalized == null || normalized.isEmpty()) {
return null;
}
String matchingConfig = null;
for (TypeDeclaration configType : context.findEntryPointClasses(java.util.List.of("EnableStateMachine"))) {
String configFqn = context.getFqn(configType);
String configuredName = extractEnableStateMachineName(configType);
if (configuredName == null || configuredName.isEmpty()) {
configuredName = defaultBeanName(configType.getName().getIdentifier());
}
if (normalized.equals(configuredName)) {
matchingConfig = configFqn;
break;
}
}
if (matchingConfig == null) {
return null;
}
return currentMachineName.equals(matchingConfig);
}
private static String extractEnableStateMachineName(TypeDeclaration configType) {
for (Object modifierObj : configType.modifiers()) {
if (!(modifierObj instanceof Annotation annotation)) {
continue;
}
if (!annotation.getTypeName().toString().endsWith("EnableStateMachine")) {
continue;
}
String name = AstUtils.extractAnnotationMember(annotation, "name");
if (name != null && !name.isBlank()) {
return stripAnnotationQuotes(name);
}
String value = AstUtils.extractAnnotationMember(annotation, "value");
if (value != null && !value.isBlank()) {
return stripAnnotationQuotes(value);
}
}
return null;
}
private static String defaultBeanName(String simpleClassName) {
if (simpleClassName == null || simpleClassName.isEmpty()) {
return simpleClassName;
}
return Character.toLowerCase(simpleClassName.charAt(0)) + simpleClassName.substring(1);
}
private static String stripAnnotationQuotes(String raw) {
if (raw == null || raw.isBlank()) {
return null;
}
String trimmed = raw.trim();
if (trimmed.length() >= 2 && trimmed.startsWith("\"") && trimmed.endsWith("\"")) {
return trimmed.substring(1, trimmed.length() - 1);
}
return trimmed;
}
private int countStateMachines(CodebaseContext context) {
if (context == null) {
return 0;

View File

@@ -97,6 +97,7 @@ public class GenericEventDetector {
if (sourceState == null) {
sourceState = extractSourceStateFromArguments(node);
}
String stateMachineId = extractStateMachineId(node);
String[] smTypes = resolveStateMachineTypeArgumentsForExpression(emr.getExpression(), node);
boolean external = false;
@@ -110,6 +111,7 @@ public class GenericEventDetector {
triggers.add(TriggerPoint.builder()
.event(eventValue)
.sourceState(sourceState)
.stateMachineId(stateMachineId)
.className(context.getFqn(type))
.methodName(method != null ? method.getName().getIdentifier() : "initializer")
.sourceFile(context.getRelativePath(context.getFqn(type)))
@@ -311,6 +313,7 @@ public class GenericEventDetector {
if (sourceState == null && node instanceof MethodInvocation mi) {
sourceState = extractSourceStateFromArguments(mi);
}
String stateMachineId = extractStateMachineId(node);
String[] smTypes = resolveStateMachineTypeArguments(node);
boolean external = false;
@@ -330,6 +333,7 @@ public class GenericEventDetector {
results.add(TriggerPoint.builder()
.event(part.trim())
.sourceState(sourceState)
.stateMachineId(stateMachineId)
.className(context.getFqn(type))
.methodName(method != null ? method.getName().getIdentifier() : "initializer")
.sourceFile(context.getRelativePath(context.getFqn(type)))
@@ -345,6 +349,7 @@ public class GenericEventDetector {
results.add(TriggerPoint.builder()
.event(eventValue)
.sourceState(sourceState)
.stateMachineId(stateMachineId)
.className(context.getFqn(type))
.methodName(method != null ? method.getName().getIdentifier() : "initializer")
.sourceFile(context.getRelativePath(context.getFqn(type)))
@@ -383,6 +388,21 @@ public class GenericEventDetector {
String state = extractStateFromSiblings(current, switchStmt.statements());
if (state != null) return state;
}
} else if (parent instanceof SwitchExpression switchExpr) {
if (!isRoutingParameter(switchExpr.getExpression())) {
String state = extractStateFromSiblings(current, switchExpr.statements());
if (state != null) return state;
}
} else if (parent instanceof SwitchCase switchCase && !switchCase.isDefault()) {
Expression selector = resolveSwitchSelector(switchCase);
if (selector != null && !isRoutingParameter(selector) && !switchCase.expressions().isEmpty()) {
Expression caseExpr = (Expression) switchCase.expressions().get(0);
String resolved = resolveProvableStateLiteral(caseExpr);
if (resolved != null) {
return resolved;
}
return getSimpleNameString(caseExpr);
}
}
current = parent;
@@ -390,6 +410,151 @@ public class GenericEventDetector {
return null;
}
private Expression resolveSwitchSelector(SwitchCase switchCase) {
ASTNode switchParent = switchCase.getParent();
if (switchParent instanceof SwitchStatement switchStatement) {
return switchStatement.getExpression();
}
if (switchParent instanceof SwitchExpression switchExpression) {
return switchExpression.getExpression();
}
return null;
}
private String extractStateMachineId(MethodInvocation node) {
if (!TRIGGER_METHOD_NAMES.contains(node.getName().getIdentifier())) {
return null;
}
Expression receiver = node.getExpression();
while (receiver instanceof MethodInvocation methodInvocation) {
receiver = methodInvocation.getExpression();
}
if (receiver == null) {
return null;
}
AbstractTypeDeclaration enclosing = findEnclosingAbstractType(node);
if (enclosing == null) {
return null;
}
return resolveQualifierForReceiver(receiver, enclosing);
}
private String resolveQualifierForReceiver(Expression receiver, AbstractTypeDeclaration enclosing) {
String fieldName = null;
if (receiver instanceof SimpleName simpleName) {
fieldName = simpleName.getIdentifier();
IBinding binding = simpleName.resolveBinding();
if (binding instanceof IVariableBinding variableBinding) {
String fromBinding = extractQualifierFromBinding(variableBinding);
if (fromBinding != null) {
return fromBinding;
}
}
} else if (receiver instanceof FieldAccess fieldAccess) {
fieldName = fieldAccess.getName().getIdentifier();
}
if (fieldName == null) {
return null;
}
return findQualifierOnField(enclosing, fieldName);
}
private String findQualifierOnField(AbstractTypeDeclaration enclosing, String fieldName) {
for (FieldDeclaration fieldDeclaration : getFieldDeclarations(enclosing)) {
for (Object fragmentObj : fieldDeclaration.fragments()) {
if (fragmentObj instanceof VariableDeclarationFragment fragment
&& fragment.getName().getIdentifier().equals(fieldName)) {
String qualifier = extractQualifierAnnotation(fieldDeclaration.modifiers());
if (qualifier != null) {
return qualifier;
}
}
}
}
return null;
}
private String extractQualifierFromBinding(IVariableBinding binding) {
if (binding == null) {
return null;
}
String qualifier = readQualifierFromAnnotations(binding.getAnnotations());
if (qualifier != null) {
return qualifier;
}
if (binding.isField() && binding.getDeclaringClass() != null) {
for (IMethodBinding method : binding.getDeclaringClass().getDeclaredMethods()) {
if (!method.isConstructor()) {
continue;
}
for (int i = 0; i < method.getParameterTypes().length; i++) {
if (method.getParameterTypes()[i].isEqualTo(binding.getType().getErasure())
&& binding.getName().equals(method.getParameterNames()[i])) {
String paramQualifier = readQualifierFromAnnotations(method.getParameterAnnotations(i));
if (paramQualifier != null) {
return paramQualifier;
}
}
}
}
}
return null;
}
private String readQualifierFromAnnotations(IAnnotationBinding[] annotations) {
if (annotations == null) {
return null;
}
for (IAnnotationBinding annotation : annotations) {
if (annotation.getAnnotationType() == null
|| !"org.springframework.beans.factory.annotation.Qualifier"
.equals(annotation.getAnnotationType().getQualifiedName())) {
continue;
}
for (IMemberValuePairBinding pair : annotation.getDeclaredMemberValuePairs()) {
Object value = pair.getValue();
if (value instanceof String stringValue) {
return stringValue;
}
}
}
return null;
}
private String extractQualifierAnnotation(List<?> modifiers) {
if (modifiers == null) {
return null;
}
for (Object modifierObj : modifiers) {
if (!(modifierObj instanceof Annotation annotation)) {
continue;
}
String typeName = annotation.getTypeName().toString();
if (!typeName.endsWith("Qualifier")) {
continue;
}
String value = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractAnnotationMember(
annotation, "value");
if (value == null || value.isBlank()) {
value = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractAnnotationMember(
annotation, "name");
}
return stripAnnotationQuotes(value);
}
return null;
}
private static String stripAnnotationQuotes(String raw) {
if (raw == null || raw.isBlank()) {
return null;
}
String trimmed = raw.trim();
if (trimmed.length() >= 2 && trimmed.startsWith("\"") && trimmed.endsWith("\"")) {
return trimmed.substring(1, trimmed.length() - 1);
}
return trimmed;
}
/**
* Infers source state from a literal second argument to sendEvent(event, sourceState) when provable from AST.
*/