diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/TransitionLinkerEnricher.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/TransitionLinkerEnricher.java index 063c12e..76fd71d 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/TransitionLinkerEnricher.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/TransitionLinkerEnricher.java @@ -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; + } + } \ No newline at end of file diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/routing/SharedServiceRoutingPolicy.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/routing/SharedServiceRoutingPolicy.java index 2108c33..afbf66a 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/routing/SharedServiceRoutingPolicy.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/routing/SharedServiceRoutingPolicy.java @@ -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; } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AbstractCallGraphEngine.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AbstractCallGraphEngine.java index 730cefe..96a65a0 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AbstractCallGraphEngine.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AbstractCallGraphEngine.java @@ -131,6 +131,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { Map 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) diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/GenericEventDetector.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/GenericEventDetector.java index 92442e1..439d904 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/GenericEventDetector.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/GenericEventDetector.java @@ -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 impls = context.getImplementations(className); if (impls == null || impls.isEmpty()) return typeVarName; + LinkedHashSet 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; } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/TriggerMachineTypeRebinder.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/TriggerMachineTypeRebinder.java new file mode 100644 index 0000000..b8c4a74 --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/TriggerMachineTypeRebinder.java @@ -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} type arguments on a trigger using the concrete + * subclass parameterization present on the call chain. + *

+ * 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}), + * this binder substitutes the concrete types from that extends clause. + */ +public final class TriggerMachineTypeRebinder { + + private TriggerMachineTypeRebinder() { + } + + public static TriggerPoint rebind(TriggerPoint trigger, List 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 stateCandidates = new LinkedHashSet<>(); + LinkedHashSet eventCandidates = new LinkedHashSet<>(); + + if (ownerFqn != null && methodChain != null && !methodChain.isEmpty()) { + TypeDeclaration ownerTd = context.getTypeDeclaration(ownerFqn); + List 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 stateCandidates, + Set 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 typeParameterNames(TypeDeclaration typeDeclaration) { + if (typeDeclaration == null) { + return List.of(); + } + List 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 ownerTypeParams) { + if (stateTypeFqn != null && ownerTypeParams.contains(stateTypeFqn)) { + return ownerTypeParams.indexOf(stateTypeFqn); + } + return ownerTypeParams.isEmpty() ? -1 : 0; + } + + private static int resolveEventParamIndex(String eventTypeFqn, List ownerTypeParams) { + if (eventTypeFqn != null && ownerTypeParams.contains(eventTypeFqn)) { + return ownerTypeParams.indexOf(eventTypeFqn); + } + return ownerTypeParams.size() >= 2 ? 1 : -1; + } + + private static String uniqueConcrete(Set candidates) { + if (candidates == null || candidates.size() != 1) { + return null; + } + return candidates.iterator().next(); + } + + private static String commonPolymorphicEventOwner(List polymorphicEvents) { + if (polymorphicEvents == null || polymorphicEvents.isEmpty()) { + return null; + } + String owner = null; + for (String event : polymorphicEvents) { + if (event == null || event.isBlank() || event.startsWith(" — type args are - // unbound variables, but polymorphicEvents already resolved the concrete constant. + // Fallback path: chain has no subclass extends SharedService, + // so TriggerMachineTypeRebinder cannot stamp concrete FQNs. Shared-infra + poly remains. CallChain chain = CallChain.builder() .triggerPoint(TriggerPoint.builder() .event("com.example.order.OrderEvent.PAY") diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/SharedGenericBaseTypeRebindingTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/SharedGenericBaseTypeRebindingTest.java new file mode 100644 index 0000000..a120c7b --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/SharedGenericBaseTypeRebindingTest.java @@ -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 triggers = detectTriggers(context); + assertThat(triggers).isNotEmpty(); + + HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context); + + List 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 must rebind E") + .isEqualTo("com.example.OrderEvent"); + assertThat(orderTrigger.getStateTypeFqn()) + .as("OrderService extends SharedService must rebind S") + .isEqualTo("com.example.OrderState"); + assertThat(SharedServiceRoutingPolicy.isProvablySharedInfrastructure(orderChains.get(0))) + .as("concrete S/E must demote shared-infra") + .isFalse(); + + List 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 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 { + private org.springframework.statemachine.StateMachine sm; + protected void updateState(E event) { + sm.sendEvent(event); + } + } + """); + Files.writeString(javaRoot.resolve("OrderService.java"), """ + package com.example; + public class OrderService extends SharedService { + public void pay() { + updateState(OrderEvent.PAY); + } + } + """); + Files.writeString(javaRoot.resolve("DocumentService.java"), """ + package com.example; + public class DocumentService extends SharedService { + 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 {} + """); + Files.writeString(javaRoot.resolve("DocumentStateMachineConfig.java"), """ + package com.example; + @org.springframework.statemachine.config.EnableStateMachine + public class DocumentStateMachineConfig + extends org.springframework.statemachine.config.StateMachineConfigurerAdapter {} + """); + } + + 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; + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/TriggerMachineTypeRebinderTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/TriggerMachineTypeRebinderTest.java new file mode 100644 index 0000000..c06664d --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/TriggerMachineTypeRebinderTest.java @@ -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 { + org.springframework.statemachine.StateMachine sm; + void updateState(E event) { sm.sendEvent(event); } + } + class OrderService extends SharedService { + void pay() { updateState(OrderEvent.PAY); } + } + class DocumentService extends SharedService { + 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 } + """); + } +}