From 60b5ceea8c4d46ae8cb2f187fabe56a6ed463a0c Mon Sep 17 00:00:00 2001 From: Kamil Patryk Kozakowski Date: Mon, 13 Jul 2026 20:39:51 +0200 Subject: [PATCH] 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 --- .../EnableStateMachineBeanRouting.java | 87 +++++++++++++ .../HeuristicBeanResolutionEngine.java | 97 ++++----------- .../routing/MachineRoutingEvidence.java | 44 +++++++ .../routing/SpringInjectionRouting.java | 99 +++++++++++++++ .../ast/common/CodebaseContext.java | 4 + .../routing/SpringInjectionRoutingTest.java | 114 ++++++++++++++++++ 6 files changed, 373 insertions(+), 72 deletions(-) create mode 100644 state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/routing/EnableStateMachineBeanRouting.java create mode 100644 state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/routing/MachineRoutingEvidence.java create mode 100644 state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/routing/SpringInjectionRouting.java create mode 100644 state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/routing/SpringInjectionRoutingTest.java diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/routing/EnableStateMachineBeanRouting.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/routing/EnableStateMachineBeanRouting.java new file mode 100644 index 0000000..b763c77 --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/routing/EnableStateMachineBeanRouting.java @@ -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; + } +} 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 689472b..5368638 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 @@ -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.TriggerPoint; 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.model.Transition; -import org.eclipse.jdt.core.dom.Annotation; import org.eclipse.jdt.core.dom.TypeDeclaration; import java.util.HashSet; @@ -37,9 +35,12 @@ public class HeuristicBeanResolutionEngine implements BeanResolutionEngine { 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) { - Boolean namedTarget = resolveNamedBeanTarget(explicitTarget, currentMachineName, context); + Boolean namedTarget = EnableStateMachineBeanRouting.matchesNamedBean( + explicitTarget, currentMachineName, context); if (namedTarget != null) { return namedTarget; } @@ -70,9 +71,12 @@ public class HeuristicBeanResolutionEngine implements BeanResolutionEngine { @Override 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) { - Boolean namedTarget = resolveNamedBeanTarget(explicitTarget, currentMachineName, context); + Boolean namedTarget = EnableStateMachineBeanRouting.matchesNamedBean( + explicitTarget, currentMachineName, context); if (namedTarget != null) { 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(); if (targetVar == null && chain.getTriggerPoint() != null) { targetVar = chain.getTriggerPoint().getStateMachineId(); @@ -201,17 +214,6 @@ public class HeuristicBeanResolutionEngine implements BeanResolutionEngine { 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( TriggerPoint trigger, String machineName, CodebaseContext context) { String triggerEventFqn = trigger.getEventTypeFqn(); @@ -301,64 +303,15 @@ public class HeuristicBeanResolutionEngine implements BeanResolutionEngine { } 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); + return EnableStateMachineBeanRouting.matchesNamedBean(beanName, currentMachineName, context); } - 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); - } + private boolean isAmbiguousSharedGenericTrigger(TriggerPoint trigger) { + if (trigger == null) { + return false; } - 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; + return isAmbiguousSharedGenericType(trigger.getEventTypeFqn()) + || isAmbiguousSharedGenericType(trigger.getStateTypeFqn()); } private int countStateMachines(CodebaseContext context) { diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/routing/MachineRoutingEvidence.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/routing/MachineRoutingEvidence.java new file mode 100644 index 0000000..4114f66 --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/routing/MachineRoutingEvidence.java @@ -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); + } +} diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/routing/SpringInjectionRouting.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/routing/SpringInjectionRouting.java new file mode 100644 index 0000000..02b7708 --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/routing/SpringInjectionRouting.java @@ -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 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)); + } +} diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/CodebaseContext.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/CodebaseContext.java index 4913b18..6d6ade7 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/CodebaseContext.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/CodebaseContext.java @@ -194,6 +194,10 @@ public class CodebaseContext { this.resolveBindings = resolveBindings; } + public boolean isResolveBindings() { + return resolveBindings; + } + public void setActiveProfiles(List profiles) { this.activeProfiles.clear(); if (profiles != null) { diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/routing/SpringInjectionRoutingTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/routing/SpringInjectionRoutingTest.java new file mode 100644 index 0000000..be9bbc9 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/routing/SpringInjectionRoutingTest.java @@ -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 stateMachine; + public PaymentService(StateMachine 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 {} + @org.springframework.context.annotation.Configuration + @org.springframework.statemachine.config.EnableStateMachine(name = "orderStateMachine") + class OrderStateMachineConfig + extends org.springframework.statemachine.config.StateMachineConfigurerAdapter {} + """); + + 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 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 {} + @org.springframework.context.annotation.Configuration + @org.springframework.statemachine.config.EnableStateMachine(name = "machineB") + class MachineBConfig + extends org.springframework.statemachine.config.StateMachineConfigurerAdapter {} + """); + + 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(); + } +}