This commit is contained in:
2026-07-16 21:37:53 +02:00
parent 961873d29c
commit 2fc5ad53d5
8 changed files with 703 additions and 13 deletions

View File

@@ -25,9 +25,11 @@ import click.kamil.springstatemachineexporter.analysis.enricher.matching.EventMa
import click.kamil.springstatemachineexporter.analysis.enricher.matching.StrictFqnMatchingEngine;
import click.kamil.springstatemachineexporter.analysis.enricher.routing.BeanResolutionEngine;
import click.kamil.springstatemachineexporter.analysis.enricher.routing.HeuristicBeanResolutionEngine;
import click.kamil.springstatemachineexporter.analysis.enricher.routing.SharedServiceRoutingPolicy;
import click.kamil.springstatemachineexporter.analysis.resolver.BooleanConstraintEvaluator;
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver;
import click.kamil.springstatemachineexporter.analysis.service.TriggerMachineTypeRebinder;
public class TransitionLinkerEnricher implements AnalysisEnricher {
@@ -73,14 +75,11 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
tp = CallChainPolyNarrower.narrowForCallChain(
tp, chain, effectiveMachineTypes, result.getEventTypeFqn(), stateMachineTransitions, context);
tp = MachineEnumCanonicalizer.canonicalizeTriggerLabelsForLinking(tp, effectiveMachineTypes, context);
tp = TriggerMachineTypeRebinder.rebind(tp, chain.getMethodChain(), context);
tp = CallChainLinkPolicy.applyRestExternalPolicy(tp, chain, context);
tp = tp.toBuilder()
.eventTypeFqn(tp.getEventTypeFqn() != null
? tp.getEventTypeFqn()
: effectiveEventTypeFqn)
.stateTypeFqn(tp.getStateTypeFqn() != null
? tp.getStateTypeFqn()
: effectiveMachineTypes.stateTypeFqn())
.eventTypeFqn(concreteOrFallback(tp.getEventTypeFqn(), effectiveEventTypeFqn))
.stateTypeFqn(concreteOrFallback(tp.getStateTypeFqn(), effectiveMachineTypes.stateTypeFqn()))
.build();
if (LifecycleTriggerMarkers.isLifecycle(tp.getEvent())) {
@@ -461,4 +460,11 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
== MachineEnumCanonicalizer.TriggerEventKind.CANONICAL_ENUM;
}
private static String concreteOrFallback(String triggerTypeFqn, String machineTypeFqn) {
if (SharedServiceRoutingPolicy.isConcreteMachineType(triggerTypeFqn)) {
return triggerTypeFqn;
}
return machineTypeFqn;
}
}

View File

@@ -12,7 +12,7 @@ import click.kamil.springstatemachineexporter.analysis.model.CallChain;
* ({@code Object}) are treated as absent — not as proven types. Event-name matching alone is never
* sufficient outside this policy.
*/
final class SharedServiceRoutingPolicy {
public final class SharedServiceRoutingPolicy {
private SharedServiceRoutingPolicy() {
}
@@ -20,7 +20,7 @@ final class SharedServiceRoutingPolicy {
/**
* @return true when the chain may multi-attach to every machine that shares its transition event
*/
static boolean isProvablySharedInfrastructure(CallChain chain) {
public static boolean isProvablySharedInfrastructure(CallChain chain) {
if (chain == null) {
return false;
}
@@ -35,7 +35,7 @@ final class SharedServiceRoutingPolicy {
* 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) {
public static boolean isConcreteMachineType(String typeFqn) {
if (typeFqn == null || typeFqn.isBlank()) {
return false;
}

View File

@@ -131,6 +131,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
Map<String, String> pathBindings = resolvePathBindings(path, callGraph, initialBindings);
TriggerPoint resolvedTp = resolveTriggerPointParameters(tp, path, callGraph, initialBindings);
if (resolvedTp != null) {
resolvedTp = TriggerMachineTypeRebinder.rebind(resolvedTp, path, context);
String contextMachineId = pathFinder.extractContextMachineId(path, callGraph);
chains.add(CallChain.builder()
.entryPoint(chainEntryPoint)

View File

@@ -10,6 +10,7 @@ import org.eclipse.jdt.core.dom.*;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
@@ -960,6 +961,7 @@ public class GenericEventDetector {
List<String> impls = context.getImplementations(className);
if (impls == null || impls.isEmpty()) return typeVarName;
LinkedHashSet<String> concreteCandidates = new LinkedHashSet<>();
for (String implFqn : impls) {
org.eclipse.jdt.core.dom.AbstractTypeDeclaration implNode = context.getAbstractTypeDeclaration(implFqn);
if (implNode instanceof TypeDeclaration td) {
@@ -983,12 +985,21 @@ public class GenericEventDetector {
}
if (typeIndex >= 0 && typeIndex < pt.typeArguments().size()) {
Type concreteType = (Type) pt.typeArguments().get(typeIndex);
return typeResolver.resolveTypeToFqn(concreteType, (CompilationUnit) implNode.getRoot());
String resolved = typeResolver.resolveTypeToFqn(
concreteType, (CompilationUnit) implNode.getRoot());
if (resolved != null && !isUnresolvedTypeVariableName(resolved)) {
concreteCandidates.add(resolved);
}
}
}
}
}
}
// Only stamp a concrete type when every subclass agrees — otherwise leave unbound so
// call-chain rebinding can pick the chain-scoped substitution.
if (concreteCandidates.size() == 1) {
return concreteCandidates.iterator().next();
}
return typeVarName;
}

View File

@@ -0,0 +1,249 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.enricher.routing.SharedServiceRoutingPolicy;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.AbstractTypeDeclaration;
import org.eclipse.jdt.core.dom.CompilationUnit;
import org.eclipse.jdt.core.dom.ParameterizedType;
import org.eclipse.jdt.core.dom.SimpleType;
import org.eclipse.jdt.core.dom.Type;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import org.eclipse.jdt.core.dom.TypeParameter;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
/**
* Rebinds unbound {@code StateMachine<S,E>} type arguments on a trigger using the concrete
* subclass parameterization present on the call chain.
* <p>
* Detection stamps types at the {@code sendEvent} site. On a shared base
* {@code AbstractService.updateState}, those args are often unbound {@code S}/{@code E}.
* After a chain exists (e.g. {@code OrderService extends AbstractService<OrderState, OrderEvent>}),
* this binder substitutes the concrete types from that extends clause.
*/
public final class TriggerMachineTypeRebinder {
private TriggerMachineTypeRebinder() {
}
public static TriggerPoint rebind(TriggerPoint trigger, List<String> methodChain, CodebaseContext context) {
if (trigger == null || context == null) {
return trigger;
}
boolean stateConcrete = SharedServiceRoutingPolicy.isConcreteMachineType(trigger.getStateTypeFqn());
boolean eventConcrete = SharedServiceRoutingPolicy.isConcreteMachineType(trigger.getEventTypeFqn());
if (stateConcrete && eventConcrete) {
return trigger;
}
String ownerFqn = trigger.getClassName();
LinkedHashSet<String> stateCandidates = new LinkedHashSet<>();
LinkedHashSet<String> eventCandidates = new LinkedHashSet<>();
if (ownerFqn != null && methodChain != null && !methodChain.isEmpty()) {
TypeDeclaration ownerTd = context.getTypeDeclaration(ownerFqn);
List<String> ownerTypeParams = typeParameterNames(ownerTd);
int stateParamIndex = resolveStateParamIndex(trigger.getStateTypeFqn(), ownerTypeParams);
int eventParamIndex = resolveEventParamIndex(trigger.getEventTypeFqn(), ownerTypeParams);
TypeResolver typeResolver = new TypeResolver(context);
for (String methodFqn : methodChain) {
String chainClassFqn = classNameFromMethodFqn(methodFqn);
if (chainClassFqn == null || chainClassFqn.equals(ownerFqn)) {
continue;
}
AbstractTypeDeclaration chainType = context.getAbstractTypeDeclaration(chainClassFqn);
if (!(chainType instanceof TypeDeclaration chainTd)) {
continue;
}
collectFromSupertype(
chainTd.getSuperclassType(),
ownerFqn,
stateParamIndex,
eventParamIndex,
chainTd,
typeResolver,
stateCandidates,
eventCandidates);
for (Object ifaceObj : chainTd.superInterfaceTypes()) {
if (ifaceObj instanceof Type ifaceType) {
collectFromSupertype(
ifaceType,
ownerFqn,
stateParamIndex,
eventParamIndex,
chainTd,
typeResolver,
stateCandidates,
eventCandidates);
}
}
}
}
String newState = stateConcrete
? trigger.getStateTypeFqn()
: uniqueConcrete(stateCandidates);
String newEvent = eventConcrete
? trigger.getEventTypeFqn()
: uniqueConcrete(eventCandidates);
if (!eventConcrete && newEvent == null) {
newEvent = commonPolymorphicEventOwner(trigger.getPolymorphicEvents());
}
// Drop unresolved type-variable / erasure placeholders so routing treats them as absent.
if (!stateConcrete && !SharedServiceRoutingPolicy.isConcreteMachineType(newState)) {
newState = null;
}
if (!eventConcrete && !SharedServiceRoutingPolicy.isConcreteMachineType(newEvent)) {
newEvent = null;
}
if (Objects.equals(newState, trigger.getStateTypeFqn())
&& Objects.equals(newEvent, trigger.getEventTypeFqn())) {
return trigger;
}
return trigger.toBuilder()
.stateTypeFqn(newState)
.eventTypeFqn(newEvent)
.build();
}
private static void collectFromSupertype(
Type superType,
String ownerFqn,
int stateParamIndex,
int eventParamIndex,
TypeDeclaration chainTd,
TypeResolver typeResolver,
Set<String> stateCandidates,
Set<String> eventCandidates) {
if (!(superType instanceof ParameterizedType parameterized)) {
return;
}
CompilationUnit cu = chainTd.getRoot() instanceof CompilationUnit root ? root : null;
String baseFqn = typeResolver.resolveTypeToFqn(parameterized.getType(), cu);
if (!ownerMatches(ownerFqn, baseFqn, parameterized.getType())) {
return;
}
List<?> typeArgs = parameterized.typeArguments();
if (stateParamIndex >= 0 && stateParamIndex < typeArgs.size()) {
String resolved = typeResolver.resolveTypeToFqn((Type) typeArgs.get(stateParamIndex), cu);
if (SharedServiceRoutingPolicy.isConcreteMachineType(resolved)) {
stateCandidates.add(resolved);
}
}
if (eventParamIndex >= 0 && eventParamIndex < typeArgs.size()) {
String resolved = typeResolver.resolveTypeToFqn((Type) typeArgs.get(eventParamIndex), cu);
if (SharedServiceRoutingPolicy.isConcreteMachineType(resolved)) {
eventCandidates.add(resolved);
}
}
}
private static boolean ownerMatches(String ownerFqn, String resolvedBaseFqn, Type baseType) {
if (ownerFqn == null) {
return false;
}
if (ownerFqn.equals(resolvedBaseFqn)) {
return true;
}
String simpleOwner = ownerFqn.contains(".")
? ownerFqn.substring(ownerFqn.lastIndexOf('.') + 1)
: ownerFqn;
if (resolvedBaseFqn != null
&& (resolvedBaseFqn.equals(simpleOwner) || resolvedBaseFqn.endsWith("." + simpleOwner))) {
return true;
}
if (baseType instanceof SimpleType simpleType) {
String name = simpleType.getName().getFullyQualifiedName();
return simpleOwner.equals(name) || ownerFqn.endsWith("." + name);
}
return baseType != null && simpleOwner.equals(baseType.toString());
}
private static List<String> typeParameterNames(TypeDeclaration typeDeclaration) {
if (typeDeclaration == null) {
return List.of();
}
List<String> names = new ArrayList<>();
for (Object paramObj : typeDeclaration.typeParameters()) {
if (paramObj instanceof TypeParameter typeParameter) {
names.add(typeParameter.getName().getIdentifier());
}
}
return names;
}
/**
* StateMachine&lt;S,E&gt; convention: first type param is state, second is event, unless the
* trigger still carries the type-variable name (map by name).
*/
private static int resolveStateParamIndex(String stateTypeFqn, List<String> ownerTypeParams) {
if (stateTypeFqn != null && ownerTypeParams.contains(stateTypeFqn)) {
return ownerTypeParams.indexOf(stateTypeFqn);
}
return ownerTypeParams.isEmpty() ? -1 : 0;
}
private static int resolveEventParamIndex(String eventTypeFqn, List<String> ownerTypeParams) {
if (eventTypeFqn != null && ownerTypeParams.contains(eventTypeFqn)) {
return ownerTypeParams.indexOf(eventTypeFqn);
}
return ownerTypeParams.size() >= 2 ? 1 : -1;
}
private static String uniqueConcrete(Set<String> candidates) {
if (candidates == null || candidates.size() != 1) {
return null;
}
return candidates.iterator().next();
}
private static String commonPolymorphicEventOwner(List<String> polymorphicEvents) {
if (polymorphicEvents == null || polymorphicEvents.isEmpty()) {
return null;
}
String owner = null;
for (String event : polymorphicEvents) {
if (event == null || event.isBlank() || event.startsWith("<SYMBOLIC:") || event.startsWith("ENUM_SET:")) {
continue;
}
int lastDot = event.lastIndexOf('.');
if (lastDot <= 0) {
return null;
}
String candidateOwner = event.substring(0, lastDot);
if (!SharedServiceRoutingPolicy.isConcreteMachineType(candidateOwner)) {
return null;
}
if (owner == null) {
owner = candidateOwner;
} else if (!owner.equals(candidateOwner)) {
return null;
}
}
return owner;
}
private static String classNameFromMethodFqn(String methodFqn) {
if (methodFqn == null || !methodFqn.contains(".")) {
return null;
}
String clean = methodFqn;
if (clean.contains("(")) {
clean = clean.substring(0, clean.indexOf('('));
}
int lastDot = clean.lastIndexOf('.');
if (lastDot <= 0) {
return null;
}
return clean.substring(0, lastDot);
}
}

View File

@@ -59,6 +59,8 @@ class SharedServiceRoutingPolicyTest {
@Test
void unboundGenericTypeVariablesAreTreatedAsAbsentForSharedInfrastructure() {
// Last resort only: when call-chain rebinding cannot prove concrete S/E (no subclass
// parameterization on the path), type-variable placeholders still count as absent.
CallChain chain = CallChain.builder()
.triggerPoint(TriggerPoint.builder()
.event("com.example.OrderEvent.PAY")
@@ -77,7 +79,8 @@ class SharedServiceRoutingPolicyTest {
}
@Test
void sharedGenericBaseWithTypeVariablesRoutesViaPolymorphicEvents(@TempDir Path tempDir) throws Exception {
void sharedGenericBaseWithTypeVariablesRoutesViaPolymorphicEventsAsLastResort(
@TempDir Path tempDir) throws Exception {
Files.writeString(tempDir.resolve("OrderConfig.java"), """
package com.example.order;
@org.springframework.statemachine.config.EnableStateMachine
@@ -98,8 +101,8 @@ class SharedServiceRoutingPolicyTest {
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.
// Fallback path: chain has no subclass extends SharedService<OrderState, OrderEvent>,
// so TriggerMachineTypeRebinder cannot stamp concrete FQNs. Shared-infra + poly remains.
CallChain chain = CallChain.builder()
.triggerPoint(TriggerPoint.builder()
.event("com.example.order.OrderEvent.PAY")

View File

@@ -0,0 +1,271 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.enricher.TransitionLinkerEnricher;
import click.kamil.springstatemachineexporter.analysis.enricher.routing.HeuristicBeanResolutionEngine;
import click.kamil.springstatemachineexporter.analysis.enricher.routing.SharedServiceRoutingPolicy;
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import click.kamil.springstatemachineexporter.analysis.model.LinkResolution;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.model.Event;
import click.kamil.springstatemachineexporter.model.State;
import click.kamil.springstatemachineexporter.model.Transition;
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;
/**
* End-to-end: {@code SharedService&lt;S,E&gt;} sendEvent sites start unbound; after call-chain
* resolution the subclass parameterization rebinds concrete state/event FQNs so routing uses
* type affinity instead of shared-infra fallback.
*/
class SharedGenericBaseTypeRebindingTest {
private final HeuristicBeanResolutionEngine routingEngine = new HeuristicBeanResolutionEngine();
@Test
void orderAndDocumentChainsRebindConcreteTypesAndStayIsolated(@TempDir Path tempDir) throws Exception {
writeProject(tempDir);
CodebaseContext context = scan(tempDir);
List<TriggerPoint> triggers = detectTriggers(context);
assertThat(triggers).isNotEmpty();
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
List<CallChain> orderChains = engine.findChains(
List.of(EntryPoint.builder()
.type(EntryPoint.Type.REST)
.name("POST /orders/pay")
.className("com.example.OrderController")
.methodName("pay")
.build()),
triggers);
assertThat(orderChains).isNotEmpty();
TriggerPoint orderTrigger = orderChains.get(0).getTriggerPoint();
assertThat(orderTrigger.getEventTypeFqn())
.as("OrderService extends SharedService<OrderState, OrderEvent> must rebind E")
.isEqualTo("com.example.OrderEvent");
assertThat(orderTrigger.getStateTypeFqn())
.as("OrderService extends SharedService<OrderState, OrderEvent> must rebind S")
.isEqualTo("com.example.OrderState");
assertThat(SharedServiceRoutingPolicy.isProvablySharedInfrastructure(orderChains.get(0)))
.as("concrete S/E must demote shared-infra")
.isFalse();
List<CallChain> documentChains = engine.findChains(
List.of(EntryPoint.builder()
.type(EntryPoint.Type.REST)
.name("POST /documents/submit")
.className("com.example.DocumentController")
.methodName("submit")
.build()),
triggers);
assertThat(documentChains).isNotEmpty();
TriggerPoint documentTrigger = documentChains.get(0).getTriggerPoint();
assertThat(documentTrigger.getEventTypeFqn()).isEqualTo("com.example.DocumentEvent");
assertThat(documentTrigger.getStateTypeFqn()).isEqualTo("com.example.DocumentState");
assertThat(SharedServiceRoutingPolicy.isProvablySharedInfrastructure(documentChains.get(0)))
.isFalse();
Transition pay = transition("PAY", "com.example.OrderEvent.PAY", "NEW", "PAID");
Transition submit = transition("SUBMIT", "com.example.DocumentEvent.SUBMIT", "DRAFT", "SUBMITTED");
assertThat(routingEngine.hasProvenMachineAffinity(
orderChains.get(0), "com.example.OrderStateMachineConfig", context, List.of(pay)))
.isTrue();
assertThat(routingEngine.hasProvenMachineAffinity(
orderChains.get(0), "com.example.DocumentStateMachineConfig", context, List.of(submit)))
.isFalse();
assertThat(routingEngine.hasProvenMachineAffinity(
documentChains.get(0), "com.example.DocumentStateMachineConfig", context, List.of(submit)))
.isTrue();
assertThat(routingEngine.hasProvenMachineAffinity(
documentChains.get(0), "com.example.OrderStateMachineConfig", context, List.of(pay)))
.isFalse();
AnalysisResult orderResult = AnalysisResult.builder()
.name("com.example.OrderStateMachineConfig")
.eventTypeFqn("com.example.OrderEvent")
.stateTypeFqn("com.example.OrderState")
.transitions(List.of(pay))
.metadata(CodebaseMetadata.builder().callChains(orderChains).build())
.build();
new TransitionLinkerEnricher().enrich(orderResult, context, null);
CallChain linkedOrder = orderResult.getMetadata().getCallChains().get(0);
assertThat(linkedOrder.getLinkResolution()).isEqualTo(LinkResolution.RESOLVED);
assertThat(linkedOrder.getMatchedTransitions()).hasSize(1);
assertThat(linkedOrder.getMatchedTransitions().get(0).getEvent())
.contains("PAY");
AnalysisResult documentResult = AnalysisResult.builder()
.name("com.example.DocumentStateMachineConfig")
.eventTypeFqn("com.example.DocumentEvent")
.stateTypeFqn("com.example.DocumentState")
.transitions(List.of(submit))
.metadata(CodebaseMetadata.builder().callChains(documentChains).build())
.build();
new TransitionLinkerEnricher().enrich(documentResult, context, null);
CallChain linkedDocument = documentResult.getMetadata().getCallChains().get(0);
assertThat(linkedDocument.getLinkResolution()).isEqualTo(LinkResolution.RESOLVED);
assertThat(linkedDocument.getMatchedTransitions()).hasSize(1);
assertThat(linkedDocument.getMatchedTransitions().get(0).getEvent())
.contains("SUBMIT");
}
@Test
void stillUnboundWithoutSubclassOnPathKeepsSharedInfraFallback(@TempDir Path tempDir) throws Exception {
writeProject(tempDir);
CodebaseContext context = scan(tempDir);
// No OrderService/DocumentService on the chain — rebinding cannot prove a unique S/E.
CallChain unboundChain = CallChain.builder()
.triggerPoint(TriggerPoint.builder()
.className("com.example.SharedService")
.methodName("updateState")
.event("com.example.OrderEvent.PAY")
.polymorphicEvents(List.of("com.example.OrderEvent.PAY"))
.eventTypeFqn("E")
.stateTypeFqn("S")
.build())
.methodChain(List.of(
"com.example.CommonController.dispatch()",
"com.example.SharedService.updateState()"))
.build();
TriggerPoint rebound = TriggerMachineTypeRebinder.rebind(
unboundChain.getTriggerPoint(), unboundChain.getMethodChain(), context);
// Poly-owner may recover event type; state stays unbound without subclass args.
assertThat(rebound.getStateTypeFqn()).isNull();
CallChain afterRebind = unboundChain.toBuilder().triggerPoint(rebound).build();
Transition pay = transition("PAY", "com.example.OrderEvent.PAY", "NEW", "PAID");
// With only event type rebound (or still absent types + poly), affinity to order machine
// remains via poly / shared-infra — not via invented Document types.
assertThat(routingEngine.hasProvenMachineAffinity(
afterRebind, "com.example.OrderStateMachineConfig", context, List.of(pay)))
.isTrue();
}
private static List<TriggerPoint> detectTriggers(CodebaseContext context) {
GenericEventDetector detector = new GenericEventDetector(
context, context.getConstantResolver(), List.of());
return context.getCompilationUnits().stream()
.flatMap(cu -> detector.detect(cu).stream())
.filter(tp -> "updateState".equals(tp.getMethodName())
|| "sendEvent".equals(tp.getMethodName()))
.toList();
}
private static CodebaseContext scan(Path tempDir) throws Exception {
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.setSourcepath(List.of(tempDir.resolve("src/main/java").toString()));
context.scan(tempDir);
return context;
}
private static void writeProject(Path tempDir) throws Exception {
Path javaRoot = tempDir.resolve("src/main/java/com/example");
Files.createDirectories(javaRoot);
Files.writeString(tempDir.resolve("build.gradle"), "plugins { id 'java' }");
Files.writeString(javaRoot.resolve("OrderEvent.java"), """
package com.example;
public enum OrderEvent { PAY }
""");
Files.writeString(javaRoot.resolve("OrderState.java"), """
package com.example;
public enum OrderState { NEW, PAID }
""");
Files.writeString(javaRoot.resolve("DocumentEvent.java"), """
package com.example;
public enum DocumentEvent { SUBMIT }
""");
Files.writeString(javaRoot.resolve("DocumentState.java"), """
package com.example;
public enum DocumentState { DRAFT, SUBMITTED }
""");
Files.writeString(javaRoot.resolve("SharedService.java"), """
package com.example;
public abstract class SharedService<S, E> {
private org.springframework.statemachine.StateMachine<S, E> sm;
protected void updateState(E event) {
sm.sendEvent(event);
}
}
""");
Files.writeString(javaRoot.resolve("OrderService.java"), """
package com.example;
public class OrderService extends SharedService<OrderState, OrderEvent> {
public void pay() {
updateState(OrderEvent.PAY);
}
}
""");
Files.writeString(javaRoot.resolve("DocumentService.java"), """
package com.example;
public class DocumentService extends SharedService<DocumentState, DocumentEvent> {
public void submit() {
updateState(DocumentEvent.SUBMIT);
}
}
""");
Files.writeString(javaRoot.resolve("OrderController.java"), """
package com.example;
public class OrderController {
private final OrderService orderService;
public OrderController(OrderService orderService) {
this.orderService = orderService;
}
public void pay() {
orderService.pay();
}
}
""");
Files.writeString(javaRoot.resolve("DocumentController.java"), """
package com.example;
public class DocumentController {
private final DocumentService documentService;
public DocumentController(DocumentService documentService) {
this.documentService = documentService;
}
public void submit() {
documentService.submit();
}
}
""");
Files.writeString(javaRoot.resolve("OrderStateMachineConfig.java"), """
package com.example;
@org.springframework.statemachine.config.EnableStateMachine
public class OrderStateMachineConfig
extends org.springframework.statemachine.config.StateMachineConfigurerAdapter<OrderState, OrderEvent> {}
""");
Files.writeString(javaRoot.resolve("DocumentStateMachineConfig.java"), """
package com.example;
@org.springframework.statemachine.config.EnableStateMachine
public class DocumentStateMachineConfig
extends org.springframework.statemachine.config.StateMachineConfigurerAdapter<DocumentState, DocumentEvent> {}
""");
}
private static Transition transition(String simple, String fqn, String from, String to) {
Transition t = new Transition();
t.setSourceStates(List.of(State.of(from, from)));
t.setTargetStates(List.of(State.of(to, to)));
t.setEvent(Event.of(simple, fqn));
return t;
}
}

View File

@@ -0,0 +1,149 @@
package click.kamil.springstatemachineexporter.analysis.service;
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 TriggerMachineTypeRebinderTest {
@Test
void rebindsUnboundTypeVariablesFromSubclassParameterizationOnChain(@TempDir Path tempDir) throws Exception {
writeSharedBaseFixture(tempDir);
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.scan(tempDir);
TriggerPoint unbound = TriggerPoint.builder()
.className("com.example.SharedService")
.methodName("updateState")
.event("event")
.stateTypeFqn("S")
.eventTypeFqn("E")
.build();
TriggerPoint rebound = TriggerMachineTypeRebinder.rebind(
unbound,
List.of(
"com.example.OrderController.pay()",
"com.example.OrderService.pay()",
"com.example.SharedService.updateState()"),
context);
assertThat(rebound.getStateTypeFqn()).isEqualTo("com.example.OrderState");
assertThat(rebound.getEventTypeFqn()).isEqualTo("com.example.OrderEvent");
}
@Test
void doesNotInventTypeWhenChainHasConflictingSubclassParameterizations(@TempDir Path tempDir) throws Exception {
writeSharedBaseFixture(tempDir);
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.scan(tempDir);
TriggerPoint unbound = TriggerPoint.builder()
.className("com.example.SharedService")
.methodName("updateState")
.event("event")
.stateTypeFqn("S")
.eventTypeFqn("E")
.build();
// Both concrete subclasses on one chain → conflict → leave unbound (cleared to null).
TriggerPoint rebound = TriggerMachineTypeRebinder.rebind(
unbound,
List.of(
"com.example.OrderService.pay()",
"com.example.DocumentService.submit()",
"com.example.SharedService.updateState()"),
context);
assertThat(rebound.getStateTypeFqn()).isNull();
assertThat(rebound.getEventTypeFqn()).isNull();
}
@Test
void infersEventTypeOwnerFromConcretePolymorphicEventsWhenStillUnbound(@TempDir Path tempDir) throws Exception {
writeSharedBaseFixture(tempDir);
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.scan(tempDir);
TriggerPoint unbound = TriggerPoint.builder()
.className("com.example.SharedService")
.methodName("updateState")
.event("event")
.stateTypeFqn("S")
.eventTypeFqn("E")
.polymorphicEvents(List.of("com.example.OrderEvent.PAY"))
.build();
// Chain has no subclass extends clause (controller only) → poly-owner fallback for event.
TriggerPoint rebound = TriggerMachineTypeRebinder.rebind(
unbound,
List.of(
"com.example.OrderController.pay()",
"com.example.SharedService.updateState()"),
context);
assertThat(rebound.getEventTypeFqn()).isEqualTo("com.example.OrderEvent");
assertThat(rebound.getStateTypeFqn()).isNull();
}
@Test
void leavesConcreteTypesUntouched(@TempDir Path tempDir) throws Exception {
writeSharedBaseFixture(tempDir);
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.scan(tempDir);
TriggerPoint concrete = TriggerPoint.builder()
.className("com.example.SharedService")
.methodName("updateState")
.event("com.example.OrderEvent.PAY")
.stateTypeFqn("com.example.OrderState")
.eventTypeFqn("com.example.OrderEvent")
.build();
TriggerPoint rebound = TriggerMachineTypeRebinder.rebind(
concrete,
List.of("com.example.DocumentService.submit()", "com.example.SharedService.updateState()"),
context);
assertThat(rebound).isSameAs(concrete);
}
private static void writeSharedBaseFixture(Path tempDir) throws Exception {
Files.writeString(tempDir.resolve("Shared.java"), """
package com.example;
abstract class SharedService<S, E> {
org.springframework.statemachine.StateMachine<S, E> sm;
void updateState(E event) { sm.sendEvent(event); }
}
class OrderService extends SharedService<OrderState, OrderEvent> {
void pay() { updateState(OrderEvent.PAY); }
}
class DocumentService extends SharedService<DocumentState, DocumentEvent> {
void submit() { updateState(DocumentEvent.SUBMIT); }
}
class OrderController {
OrderService orderService;
void pay() { orderService.pay(); }
}
enum OrderEvent { PAY }
enum OrderState { NEW, PAID }
enum DocumentEvent { SUBMIT }
enum DocumentState { DRAFT }
""");
}
}