Extract machine routing helpers and gate Spring DI to ambiguous generics.

Keep package heuristics for single-machine codebases and fail closed only when String/Object generic triggers are provably ambiguous across multiple beans.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-13 20:39:51 +02:00
parent 423bb4e819
commit 60b5ceea8c
6 changed files with 373 additions and 72 deletions

View File

@@ -0,0 +1,87 @@
package click.kamil.springstatemachineexporter.analysis.enricher.routing;
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;
/**
* Maps {@code @EnableStateMachine(name=…)} bean names to configuration class FQNs.
*/
final class EnableStateMachineBeanRouting {
private EnableStateMachineBeanRouting() {
}
static Boolean matchesNamedBean(String beanName, String currentMachineConfigFqn, CodebaseContext context) {
String normalized = stripQuotes(beanName);
if (normalized == null || normalized.isEmpty() || context == null || currentMachineConfigFqn == null) {
return null;
}
String matchingConfig = findConfigFqnByBeanName(normalized, context);
if (matchingConfig == null) {
return null;
}
return currentMachineConfigFqn.equals(matchingConfig);
}
static String findConfigFqnByBeanName(String beanName, CodebaseContext context) {
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 (beanName.equals(configuredName)) {
return configFqn;
}
}
return null;
}
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 stripQuotes(name);
}
String value = AstUtils.extractAnnotationMember(annotation, "value");
if (value != null && !value.isBlank()) {
return stripQuotes(value);
}
}
return null;
}
static String defaultBeanNameFromFqn(String fqn) {
if (fqn == null || !fqn.contains(".")) {
return fqn;
}
String simple = fqn.substring(fqn.lastIndexOf('.') + 1);
return defaultBeanName(simple);
}
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 stripQuotes(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;
}
}

View File

@@ -4,10 +4,8 @@ import click.kamil.springstatemachineexporter.analysis.enricher.matching.StrictF
import click.kamil.springstatemachineexporter.analysis.model.CallChain; import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver; import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver;
import click.kamil.springstatemachineexporter.ast.common.AstUtils;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.model.Transition; import click.kamil.springstatemachineexporter.model.Transition;
import org.eclipse.jdt.core.dom.Annotation;
import org.eclipse.jdt.core.dom.TypeDeclaration; import org.eclipse.jdt.core.dom.TypeDeclaration;
import java.util.HashSet; import java.util.HashSet;
@@ -37,9 +35,12 @@ public class HeuristicBeanResolutionEngine implements BeanResolutionEngine {
return isRoutedToCorrectMachine(chain, currentMachineName, context); return isRoutedToCorrectMachine(chain, currentMachineName, context);
} }
String explicitTarget = resolveExplicitTarget(chain); MachineRoutingEvidence evidence = MachineRoutingEvidence.from(chain);
String explicitTarget = evidence.explicitBeanName();
if (explicitTarget != null && !explicitTarget.isEmpty() && context != null) { if (explicitTarget != null && !explicitTarget.isEmpty() && context != null) {
Boolean namedTarget = resolveNamedBeanTarget(explicitTarget, currentMachineName, context); Boolean namedTarget = EnableStateMachineBeanRouting.matchesNamedBean(
explicitTarget, currentMachineName, context);
if (namedTarget != null) { if (namedTarget != null) {
return namedTarget; return namedTarget;
} }
@@ -70,9 +71,12 @@ public class HeuristicBeanResolutionEngine implements BeanResolutionEngine {
@Override @Override
public boolean isRoutedToCorrectMachine(CallChain chain, String currentMachineName, CodebaseContext context) { public boolean isRoutedToCorrectMachine(CallChain chain, String currentMachineName, CodebaseContext context) {
String explicitTarget = resolveExplicitTarget(chain); MachineRoutingEvidence evidence = MachineRoutingEvidence.from(chain);
String explicitTarget = evidence.explicitBeanName();
if (explicitTarget != null && !explicitTarget.isEmpty() && context != null && currentMachineName != null) { if (explicitTarget != null && !explicitTarget.isEmpty() && context != null && currentMachineName != null) {
Boolean namedTarget = resolveNamedBeanTarget(explicitTarget, currentMachineName, context); Boolean namedTarget = EnableStateMachineBeanRouting.matchesNamedBean(
explicitTarget, currentMachineName, context);
if (namedTarget != null) { if (namedTarget != null) {
return namedTarget; return namedTarget;
} }
@@ -126,6 +130,15 @@ public class HeuristicBeanResolutionEngine implements BeanResolutionEngine {
} }
} }
if (!isSingleStateMachine(context) && isAmbiguousSharedGenericTrigger(chain.getTriggerPoint())) {
Boolean injectionMatch = SpringInjectionRouting.matchesMachineConfig(
chain.getTriggerPoint(), currentMachineName, context);
if (injectionMatch != null) {
return injectionMatch;
}
return false;
}
String targetVar = hasExplicitBeanTarget ? explicitTarget : chain.getContextMachineId(); String targetVar = hasExplicitBeanTarget ? explicitTarget : chain.getContextMachineId();
if (targetVar == null && chain.getTriggerPoint() != null) { if (targetVar == null && chain.getTriggerPoint() != null) {
targetVar = chain.getTriggerPoint().getStateMachineId(); targetVar = chain.getTriggerPoint().getStateMachineId();
@@ -201,17 +214,6 @@ public class HeuristicBeanResolutionEngine implements BeanResolutionEngine {
return context == null || isSingleStateMachine(context); return context == null || isSingleStateMachine(context);
} }
private static String resolveExplicitTarget(CallChain chain) {
if (chain == null) {
return null;
}
String explicitTarget = chain.getContextMachineId();
if (explicitTarget == null && chain.getTriggerPoint() != null) {
explicitTarget = chain.getTriggerPoint().getStateMachineId();
}
return explicitTarget;
}
private DistinctTypeAffinity resolveDistinctTypeAffinity( private DistinctTypeAffinity resolveDistinctTypeAffinity(
TriggerPoint trigger, String machineName, CodebaseContext context) { TriggerPoint trigger, String machineName, CodebaseContext context) {
String triggerEventFqn = trigger.getEventTypeFqn(); String triggerEventFqn = trigger.getEventTypeFqn();
@@ -301,64 +303,15 @@ public class HeuristicBeanResolutionEngine implements BeanResolutionEngine {
} }
private Boolean resolveNamedBeanTarget(String beanName, String currentMachineName, CodebaseContext context) { private Boolean resolveNamedBeanTarget(String beanName, String currentMachineName, CodebaseContext context) {
String normalized = stripAnnotationQuotes(beanName); return EnableStateMachineBeanRouting.matchesNamedBean(beanName, currentMachineName, context);
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) { private boolean isAmbiguousSharedGenericTrigger(TriggerPoint trigger) {
for (Object modifierObj : configType.modifiers()) { if (trigger == null) {
if (!(modifierObj instanceof Annotation annotation)) { return false;
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; return isAmbiguousSharedGenericType(trigger.getEventTypeFqn())
} || isAmbiguousSharedGenericType(trigger.getStateTypeFqn());
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) { private int countStateMachines(CodebaseContext context) {

View File

@@ -0,0 +1,44 @@
package click.kamil.springstatemachineexporter.analysis.enricher.routing;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
/**
* Source-derived evidence used to decide whether a call chain belongs to a state-machine export.
*/
public record MachineRoutingEvidence(
String explicitBeanName,
String stateMachineId,
String eventTypeFqn,
String stateTypeFqn,
String triggerClassFqn,
String triggerMethod,
Integer triggerLine) {
public static MachineRoutingEvidence from(CallChain chain) {
if (chain == null) {
return new MachineRoutingEvidence(null, null, null, null, null, null, null);
}
TriggerPoint trigger = chain.getTriggerPoint();
String explicit = chain.getContextMachineId();
String stateMachineId = null;
Integer line = null;
if (trigger != null) {
if (explicit == null) {
explicit = trigger.getStateMachineId();
}
stateMachineId = trigger.getStateMachineId();
if (trigger.getLineNumber() > 0) {
line = trigger.getLineNumber();
}
}
return new MachineRoutingEvidence(
explicit,
stateMachineId,
trigger != null ? trigger.getEventTypeFqn() : null,
trigger != null ? trigger.getStateTypeFqn() : null,
trigger != null ? trigger.getClassName() : null,
trigger != null ? trigger.getMethodName() : null,
line);
}
}

View File

@@ -0,0 +1,99 @@
package click.kamil.springstatemachineexporter.analysis.enricher.routing;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.spring.InjectionPointAnalyzer;
import click.kamil.springstatemachineexporter.analysis.spring.SpringBeanRegistry;
import click.kamil.springstatemachineexporter.analysis.spring.SpringContextScanner;
import click.kamil.springstatemachineexporter.analysis.spring.SpringDependencyResolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.*;
import java.util.Set;
/**
* Resolves which {@code @EnableStateMachine} configuration owns a trigger's {@code StateMachine} receiver
* using Spring injection analysis (JDT bindings), not package-name guessing.
*/
final class SpringInjectionRouting {
private static final Set<String> TRIGGER_METHODS = Set.of(
"sendEvent", "sendEvents", "sendEventCollect", "sendEventMono", "fire", "trigger");
private SpringInjectionRouting() {
}
static Boolean matchesMachineConfig(TriggerPoint trigger, String machineConfigFqn, CodebaseContext context) {
if (trigger == null || machineConfigFqn == null || context == null || !context.isResolveBindings()) {
return null;
}
String configFqn = resolveConfigurationFqn(trigger, context);
if (configFqn == null) {
return null;
}
return machineConfigFqn.equals(configFqn);
}
static String resolveConfigurationFqn(TriggerPoint trigger, CodebaseContext context) {
if (trigger == null || trigger.getClassName() == null || trigger.getMethodName() == null) {
return null;
}
if (!context.isResolveBindings()) {
return null;
}
InjectionPointAnalyzer analyzer = createAnalyzer(context);
TypeDeclaration type = context.getTypeDeclaration(trigger.getClassName());
if (type == null) {
return null;
}
MethodDeclaration method = context.findMethodDeclaration(type, trigger.getMethodName(), true);
if (method == null || method.getBody() == null) {
return null;
}
CompilationUnit cu = (CompilationUnit) type.getRoot();
final IVariableBinding[] receiverBinding = new IVariableBinding[1];
method.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(MethodInvocation node) {
if (trigger.getLineNumber() > 0 && cu != null
&& cu.getLineNumber(node.getStartPosition()) != trigger.getLineNumber()) {
return true;
}
if (!TRIGGER_METHODS.contains(node.getName().getIdentifier())) {
return true;
}
Expression receiver = node.getExpression();
if (receiver instanceof SimpleName sn) {
IBinding binding = sn.resolveBinding();
if (binding instanceof IVariableBinding vb) {
receiverBinding[0] = vb;
}
} else if (receiver instanceof FieldAccess fa) {
receiverBinding[0] = fa.resolveFieldBinding();
}
return false;
}
});
if (receiverBinding[0] == null) {
return null;
}
String injectedFqn = analyzer.resolveInjectedBeanFqn(receiverBinding[0]);
if (injectedFqn == null) {
return null;
}
TypeDeclaration injectedType = context.getTypeDeclaration(injectedFqn);
if (injectedType != null && context.extendsStateMachineConfigurerAdapter(injectedType)) {
return injectedFqn;
}
return EnableStateMachineBeanRouting.findConfigFqnByBeanName(
EnableStateMachineBeanRouting.defaultBeanNameFromFqn(injectedFqn), context);
}
private static InjectionPointAnalyzer createAnalyzer(CodebaseContext context) {
SpringBeanRegistry registry = new SpringBeanRegistry();
SpringContextScanner scanner = new SpringContextScanner(registry);
for (CompilationUnit cu : context.getCompilationUnits()) {
cu.accept(scanner);
}
return new InjectionPointAnalyzer(new SpringDependencyResolver(registry));
}
}

View File

@@ -194,6 +194,10 @@ public class CodebaseContext {
this.resolveBindings = resolveBindings; this.resolveBindings = resolveBindings;
} }
public boolean isResolveBindings() {
return resolveBindings;
}
public void setActiveProfiles(List<String> profiles) { public void setActiveProfiles(List<String> profiles) {
this.activeProfiles.clear(); this.activeProfiles.clear();
if (profiles != null) { if (profiles != null) {

View File

@@ -0,0 +1,114 @@
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 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 SpringInjectionRoutingTest {
@Test
void shouldRouteByQualifierWithoutPackageNameHeuristics(@TempDir Path tempDir) throws Exception {
Files.writeString(tempDir.resolve("PaymentService.java"), """
package com.example;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.statemachine.StateMachine;
import org.springframework.stereotype.Service;
@Service
public class PaymentService {
@Qualifier("paymentStateMachine")
private final StateMachine<String, String> stateMachine;
public PaymentService(StateMachine<String, String> stateMachine) {
this.stateMachine = stateMachine;
}
public void capture() {
stateMachine.sendEvent("CAPTURE");
}
}
@org.springframework.context.annotation.Configuration
@org.springframework.statemachine.config.EnableStateMachine(name = "paymentStateMachine")
class PaymentStateMachineConfig
extends org.springframework.statemachine.config.StateMachineConfigurerAdapter<String, String> {}
@org.springframework.context.annotation.Configuration
@org.springframework.statemachine.config.EnableStateMachine(name = "orderStateMachine")
class OrderStateMachineConfig
extends org.springframework.statemachine.config.StateMachineConfigurerAdapter<String, String> {}
""");
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.scan(tempDir);
HeuristicBeanResolutionEngine engine = new HeuristicBeanResolutionEngine();
CallChain chain = CallChain.builder()
.triggerPoint(TriggerPoint.builder()
.className("com.example.PaymentService")
.methodName("capture")
.lineNumber(12)
.event("CAPTURE")
.eventTypeFqn("java.lang.String")
.stateTypeFqn("java.lang.String")
.stateMachineId("paymentStateMachine")
.build())
.methodChain(List.of("com.example.PaymentService.capture()"))
.build();
assertThat(engine.isRoutedToCorrectMachine(
chain, "com.example.PaymentStateMachineConfig", context)).isTrue();
assertThat(engine.isRoutedToCorrectMachine(
chain, "com.example.OrderStateMachineConfig", context)).isFalse();
}
@Test
void shouldFailClosedForAmbiguousStringStateMachineWithoutQualifier(@TempDir Path tempDir) throws Exception {
Files.writeString(tempDir.resolve("OrderService.java"), """
package com.example;
import org.springframework.statemachine.StateMachine;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
@Service
@RequiredArgsConstructor
public class OrderService {
private final StateMachine<String, String> stateMachine;
public void finish() {
stateMachine.sendEvent("FINISH");
}
}
@org.springframework.context.annotation.Configuration
@org.springframework.statemachine.config.EnableStateMachine(name = "machineA")
class MachineAConfig
extends org.springframework.statemachine.config.StateMachineConfigurerAdapter<String, String> {}
@org.springframework.context.annotation.Configuration
@org.springframework.statemachine.config.EnableStateMachine(name = "machineB")
class MachineBConfig
extends org.springframework.statemachine.config.StateMachineConfigurerAdapter<String, String> {}
""");
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.scan(tempDir);
HeuristicBeanResolutionEngine engine = new HeuristicBeanResolutionEngine();
CallChain chain = CallChain.builder()
.triggerPoint(TriggerPoint.builder()
.className("com.example.OrderService")
.methodName("finish")
.lineNumber(11)
.event("FINISH")
.eventTypeFqn("java.lang.String")
.stateTypeFqn("java.lang.String")
.build())
.methodChain(List.of("com.example.OrderService.finish()"))
.build();
assertThat(engine.isRoutedToCorrectMachine(chain, "com.example.MachineAConfig", context)).isFalse();
assertThat(engine.isRoutedToCorrectMachine(chain, "com.example.MachineBConfig", context)).isFalse();
}
}