A
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<S,E> 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);
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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<S,E>} 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;
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
""");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user