diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/CallChainLinkPolicy.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/CallChainLinkPolicy.java index aa3cfab..8e0498c 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/CallChainLinkPolicy.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/CallChainLinkPolicy.java @@ -6,8 +6,11 @@ import click.kamil.springstatemachineexporter.analysis.model.LinkResolution; import click.kamil.springstatemachineexporter.analysis.model.MatchedTransition; import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer; +import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver; import click.kamil.springstatemachineexporter.analysis.service.ExternalTriggerPolicy; import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import click.kamil.springstatemachineexporter.model.Event; +import click.kamil.springstatemachineexporter.model.Transition; import java.util.ArrayList; import java.util.List; @@ -20,6 +23,91 @@ public final class CallChainLinkPolicy { private CallChainLinkPolicy() { } + /** + * Resolves the machine event enum FQN from config AST, embedded analysis fields, or transition events. + * Abstract/inherited configs often omit AST type args; transition {@code fullIdentifier} values still + * carry the correct enum type for endpoint narrowing and linking. + */ + public static String resolveMachineEventTypeFqn( + String machineConfigName, + String embeddedEventTypeFqn, + StateMachineTypeResolver.MachineTypes machineTypes, + List machineTransitions, + CodebaseContext context) { + if (machineTypes != null && machineTypes.eventTypeFqn() != null && !machineTypes.eventTypeFqn().isBlank()) { + return machineTypes.eventTypeFqn(); + } + if (embeddedEventTypeFqn != null && !embeddedEventTypeFqn.isBlank()) { + return embeddedEventTypeFqn; + } + if (context != null && machineConfigName != null) { + String[] types = StateMachineTypeResolver.resolve(machineConfigName, context); + if (types != null && types.length > 1 && types[1] != null && !types[1].isBlank()) { + return types[1]; + } + } + return eventTypeFromTransitions(machineTransitions); + } + + public static StateMachineTypeResolver.MachineTypes resolveEffectiveMachineTypes( + String machineConfigName, + String embeddedStateTypeFqn, + String embeddedEventTypeFqn, + StateMachineTypeResolver.MachineTypes machineTypes, + List machineTransitions, + CodebaseContext context) { + String eventTypeFqn = resolveMachineEventTypeFqn( + machineConfigName, embeddedEventTypeFqn, machineTypes, machineTransitions, context); + String stateTypeFqn = machineTypes != null && machineTypes.stateTypeFqn() != null + ? machineTypes.stateTypeFqn() + : embeddedStateTypeFqn; + if ((stateTypeFqn == null || stateTypeFqn.isBlank()) && context != null && machineConfigName != null) { + String[] types = StateMachineTypeResolver.resolve(machineConfigName, context); + if (types != null && types.length > 0 && types[0] != null && !types[0].isBlank()) { + stateTypeFqn = types[0]; + } + } + if ((stateTypeFqn == null || stateTypeFqn.isBlank()) && machineTransitions != null) { + stateTypeFqn = stateTypeFromTransitions(machineTransitions); + } + return new StateMachineTypeResolver.MachineTypes(stateTypeFqn, eventTypeFqn); + } + + private static String eventTypeFromTransitions(List machineTransitions) { + if (machineTransitions == null) { + return null; + } + for (Transition transition : machineTransitions) { + Event event = transition.getEvent(); + if (event == null) { + continue; + } + String fullIdentifier = event.fullIdentifier() != null ? event.fullIdentifier() : event.rawName(); + if (fullIdentifier != null && fullIdentifier.contains(".")) { + return fullIdentifier.substring(0, fullIdentifier.lastIndexOf('.')); + } + } + return null; + } + + private static String stateTypeFromTransitions(List machineTransitions) { + if (machineTransitions == null) { + return null; + } + for (Transition transition : machineTransitions) { + if (transition.getSourceStates() == null) { + continue; + } + for (var state : transition.getSourceStates()) { + String fullIdentifier = state.fullIdentifier() != null ? state.fullIdentifier() : state.rawName(); + if (fullIdentifier != null && fullIdentifier.contains(".")) { + return fullIdentifier.substring(0, fullIdentifier.lastIndexOf('.')); + } + } + } + return null; + } + public static boolean shouldFailClosedOnAmbiguousCallGraphWiden(TriggerPoint trigger) { return shouldFailClosedOnAmbiguousCallGraphWiden(trigger, null, null); } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/CallChainPolyNarrower.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/CallChainPolyNarrower.java index 010058f..97b9e76 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/CallChainPolyNarrower.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/CallChainPolyNarrower.java @@ -4,10 +4,19 @@ import click.kamil.springstatemachineexporter.analysis.enricher.path.SymbolicPat import click.kamil.springstatemachineexporter.analysis.model.CallChain; import click.kamil.springstatemachineexporter.analysis.model.EntryPoint; import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; +import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver; import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer; import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver; import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; import click.kamil.springstatemachineexporter.model.Transition; +import org.eclipse.jdt.core.dom.ASTVisitor; +import org.eclipse.jdt.core.dom.Expression; +import org.eclipse.jdt.core.dom.MethodDeclaration; +import org.eclipse.jdt.core.dom.MethodInvocation; +import org.eclipse.jdt.core.dom.QualifiedName; +import org.eclipse.jdt.core.dom.SimpleName; +import org.eclipse.jdt.core.dom.SingleVariableDeclaration; +import org.eclipse.jdt.core.dom.TypeDeclaration; import java.util.ArrayList; import java.util.HashMap; @@ -33,11 +42,16 @@ public final class CallChainPolyNarrower { private static final Pattern QUOTED_EQUALS_PARAM = Pattern.compile( "\"([^\"]+)\"\\.equalsIgnoreCase\\((\\w+)\\)"); + private static final Pattern PARAM_EQUALS_LITERAL = Pattern.compile( + "(\\w+)\\.equals(?:IgnoreCase)?\\(\"([^\"]+)\"\\)"); private static final Pattern ENUM_EQ = Pattern.compile( "([A-Za-z_][\\w.]*)\\s*==\\s*([A-Za-z_][\\w.]*)"); private static final Pattern ROUTE_LITERAL = Pattern.compile( "route\\(\\s*\"([^\"]+)\"\\s*,\\s*\"([^\"]+)\"\\s*\\)"); + private static final Set FIRE_METHOD_NAMES = Set.of( + "sendEvent", "fire", "fireEvent", "dispatchEvent", "send", "trigger"); + private CallChainPolyNarrower() { } @@ -47,7 +61,17 @@ public final class CallChainPolyNarrower { StateMachineTypeResolver.MachineTypes machineTypes, List machineTransitions, CodebaseContext context) { - if (trigger == null || machineTypes == null || machineTypes.eventTypeFqn() == null) { + return narrowForCallChain(trigger, chain, machineTypes, null, machineTransitions, context); + } + + public static TriggerPoint narrowForCallChain( + TriggerPoint trigger, + CallChain chain, + StateMachineTypeResolver.MachineTypes machineTypes, + String embeddedEventTypeFqn, + List machineTransitions, + CodebaseContext context) { + if (trigger == null) { return trigger; } List poly = trigger.getPolymorphicEvents(); @@ -55,7 +79,15 @@ public final class CallChainPolyNarrower { return trigger; } - String eventTypeFqn = machineTypes.eventTypeFqn(); + String eventTypeFqn = CallChainLinkPolicy.resolveMachineEventTypeFqn( + null, + embeddedEventTypeFqn != null ? embeddedEventTypeFqn : trigger.getEventTypeFqn(), + machineTypes, + machineTransitions, + context); + if (eventTypeFqn == null || eventTypeFqn.isBlank()) { + return trigger; + } Set configuredConstants = configuredEventConstants(machineTransitions, eventTypeFqn, context); if (configuredConstants.isEmpty()) { return trigger; @@ -94,6 +126,7 @@ public final class CallChainPolyNarrower { collectFromConstraint(trigger.getConstraint(), configuredConstants, votes); if (chain != null) { collectFromMethodChain(chain.getMethodChain(), configuredConstants, votes); + collectFromSendEventLiterals(chain.getMethodChain(), configuredConstants, context, votes); collectFromPathEstimator(chain, configuredConstants, context, votes); } } @@ -178,6 +211,15 @@ public final class CallChainPolyNarrower { addEnumReference(enumEq.group(1), configuredConstants, votes, MEDIUM); addEnumReference(enumEq.group(2), configuredConstants, votes, MEDIUM); } + Matcher reverseEquals = PARAM_EQUALS_LITERAL.matcher(constraint); + while (reverseEquals.find()) { + String param = reverseEquals.group(1); + String literal = reverseEquals.group(2); + if ("event".equals(param) || "eventString".equals(param) || "actionKey".equals(param) + || "action".equals(param) || "commandKey".equals(param)) { + addPathToken(literal, configuredConstants, votes, MEDIUM); + } + } } private static void collectFromMethodChain( @@ -196,6 +238,69 @@ public final class CallChainPolyNarrower { for (String token : splitCamelCase(methodName)) { addPathToken(token, configuredConstants, votes, WEAK); } + String className = methodFqn.substring(0, methodFqn.lastIndexOf('.')); + int nestedDot = className.lastIndexOf('.'); + if (nestedDot >= 0) { + addUniqueIdentifierMatch(className.substring(nestedDot + 1), configuredConstants, votes, WEAK); + } + } + } + + private static void collectFromSendEventLiterals( + List methodChain, + Set configuredConstants, + CodebaseContext context, + HintVotes votes) { + if (context == null || methodChain == null) { + return; + } + ConstantResolver resolver = new ConstantResolver(); + for (String methodFqn : methodChain) { + if (methodFqn == null || !methodFqn.contains(".")) { + continue; + } + String className = methodFqn.substring(0, methodFqn.lastIndexOf('.')); + String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1); + TypeDeclaration typeDeclaration = context.getTypeDeclaration(className); + if (typeDeclaration == null) { + continue; + } + MethodDeclaration methodDeclaration = context.findMethodDeclaration(typeDeclaration, methodName, true); + if (methodDeclaration == null) { + continue; + } + for (Object parameter : methodDeclaration.parameters()) { + if (parameter instanceof SingleVariableDeclaration variable) { + addUniqueIdentifierMatch(variable.getType().toString(), configuredConstants, votes, MEDIUM); + } + } + if (methodDeclaration.getBody() == null) { + continue; + } + methodDeclaration.getBody().accept(new ASTVisitor() { + @Override + public boolean visit(MethodInvocation node) { + if (!FIRE_METHOD_NAMES.contains(node.getName().getIdentifier())) { + return super.visit(node); + } + for (Object argument : node.arguments()) { + if (!(argument instanceof Expression expression)) { + continue; + } + if (expression instanceof QualifiedName qualifiedName) { + addEnumReference(qualifiedName.getFullyQualifiedName(), configuredConstants, votes, STRONG); + } else if (expression instanceof SimpleName simpleName) { + addEnumReference(simpleName.getIdentifier(), configuredConstants, votes, STRONG); + } else { + String resolved = resolver.resolve(expression, context); + if (resolved != null) { + addEnumReference(resolved, configuredConstants, votes, STRONG); + } + } + } + return super.visit(node); + } + }); } } @@ -399,11 +504,27 @@ public final class CallChainPolyNarrower { return null; } final int winningScore = bestScore; - long contenders = scores.entrySet().stream() - .filter(e -> configured.contains(e.getKey())) - .filter(e -> e.getValue() == winningScore) - .count(); - return contenders == 1 ? best : null; + List tied = new ArrayList<>(); + for (String constant : configured) { + if (scores.getOrDefault(constant, 0) == winningScore) { + tied.add(constant); + } + } + if (tied.size() == 1) { + return best; + } + String maxWeightWinner = null; + int bestMaxWeight = 0; + for (String constant : tied) { + int weight = maxWeight.getOrDefault(constant, 0); + if (weight > bestMaxWeight) { + bestMaxWeight = weight; + maxWeightWinner = constant; + } else if (weight == bestMaxWeight && weight > 0) { + maxWeightWinner = null; + } + } + return maxWeightWinner; } private String uniqueAboveWeight(Set configured, int minWeight) { 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 1b7290e..2d15e97 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 @@ -49,6 +49,15 @@ public class TransitionLinkerEnricher implements AnalysisEnricher { result.getStateTypeFqn(), result.getEventTypeFqn(), context); + StateMachineTypeResolver.MachineTypes effectiveMachineTypes = + CallChainLinkPolicy.resolveEffectiveMachineTypes( + result.getName(), + result.getStateTypeFqn(), + result.getEventTypeFqn(), + machineTypes, + stateMachineTransitions, + context); + String effectiveEventTypeFqn = effectiveMachineTypes.eventTypeFqn(); for (CallChain chain : result.getMetadata().getCallChains()) { TriggerPoint tp = chain.getTriggerPoint(); @@ -58,33 +67,40 @@ public class TransitionLinkerEnricher implements AnalysisEnricher { } tp = MachineEnumCanonicalizer.expandBoundValueOfFromConstraints( - tp, machineTypes, context, chain.getEntryPoint()); + tp, effectiveMachineTypes, context, chain.getEntryPoint()); tp = MachineEnumCanonicalizer.ensureCallChainPolymorphicEvents( - tp, machineTypes, context, stateMachineTransitions, true); + tp, effectiveMachineTypes, context, stateMachineTransitions, true); tp = CallChainPolyNarrower.narrowForCallChain( - tp, chain, machineTypes, stateMachineTransitions, context); - tp = MachineEnumCanonicalizer.canonicalizeTriggerLabelsForLinking(tp, machineTypes, context); + tp, chain, effectiveMachineTypes, result.getEventTypeFqn(), stateMachineTransitions, context); + tp = MachineEnumCanonicalizer.canonicalizeTriggerLabelsForLinking(tp, effectiveMachineTypes, context); tp = CallChainLinkPolicy.applyRestExternalPolicy(tp, chain, context); - if (machineTypes != null) { - tp = tp.toBuilder() - .eventTypeFqn(tp.getEventTypeFqn() != null - ? tp.getEventTypeFqn() - : machineTypes.eventTypeFqn()) - .stateTypeFqn(tp.getStateTypeFqn() != null - ? tp.getStateTypeFqn() - : machineTypes.stateTypeFqn()) - .build(); - } + tp = tp.toBuilder() + .eventTypeFqn(tp.getEventTypeFqn() != null + ? tp.getEventTypeFqn() + : effectiveEventTypeFqn) + .stateTypeFqn(tp.getStateTypeFqn() != null + ? tp.getStateTypeFqn() + : effectiveMachineTypes.stateTypeFqn()) + .build(); if (LifecycleTriggerMarkers.isLifecycle(tp.getEvent())) { updatedChains.add(chain); continue; } + if (tp.isExternal() && isUnresolvedGenericRestEndpoint(chain.getEntryPoint())) { + updatedChains.add(chain.toBuilder() + .triggerPoint(tp) + .matchedTransitions(null) + .linkResolution(LinkResolution.UNRESOLVED_EXTERNAL) + .build()); + continue; + } + String triggerSource = tp.getSourceState() != null ? simplifySourceState(tp.getSourceState()) : null; if (CallChainLinkPolicy.shouldFailClosedOnAmbiguousCallGraphWiden( tp, - machineTypes != null ? machineTypes.eventTypeFqn() : null, + effectiveEventTypeFqn, context)) { updatedChains.add(chain.toBuilder() .triggerPoint(tp) @@ -104,7 +120,7 @@ public class TransitionLinkerEnricher implements AnalysisEnricher { if (t.getEvent() != null && matchingEngine.matches(t.getEvent(), tp) && isRoutedToCorrectMachine( chain, result.getName(), context, stateMachineTransitions, tp, - machineTypes != null ? machineTypes.eventTypeFqn() : null) + effectiveEventTypeFqn) && isConstraintCompatible(tp.getConstraint(), result.getName())) { String smEventForLink = canonicalEvent(t.getEvent()); for (State smSourceState : t.getSourceStates()) { @@ -125,7 +141,7 @@ public class TransitionLinkerEnricher implements AnalysisEnricher { } else if (sourcesByEvent.values().stream().anyMatch(sources -> sources.size() > 1)) { ambiguousSource = !CallChainLinkPolicy.isEndpointNarrowPolyEvidence( tp, - machineTypes != null ? machineTypes.eventTypeFqn() : null, + effectiveEventTypeFqn, context); } else { Set allDistinctSources = new LinkedHashSet<>(); @@ -157,7 +173,7 @@ public class TransitionLinkerEnricher implements AnalysisEnricher { .build(); if (isRoutedToCorrectMachine( chain, result.getName(), context, stateMachineTransitions, tp, - machineTypes != null ? machineTypes.eventTypeFqn() : null) + effectiveEventTypeFqn) && isConstraintCompatible(tp.getConstraint(), result.getName())) { matched.add(mt); } @@ -171,7 +187,7 @@ public class TransitionLinkerEnricher implements AnalysisEnricher { .build(); if (isRoutedToCorrectMachine( chain, result.getName(), context, stateMachineTransitions, tp, - machineTypes != null ? machineTypes.eventTypeFqn() : null) + effectiveEventTypeFqn) && isConstraintCompatible(tp.getConstraint(), result.getName())) { matched.add(mt); } @@ -191,7 +207,7 @@ public class TransitionLinkerEnricher implements AnalysisEnricher { tp, matched, ambiguousSource, - machineTypes != null ? machineTypes.eventTypeFqn() : null, + effectiveEventTypeFqn, context); if (!matched.isEmpty()) { @@ -316,4 +332,20 @@ public class TransitionLinkerEnricher implements AnalysisEnricher { return event.fullIdentifier() != null ? event.fullIdentifier() : event.rawName(); } + private static boolean isUnresolvedGenericRestEndpoint(EntryPoint entryPoint) { + if (entryPoint == null) { + return false; + } + if (entryPoint.getName() != null && entryPoint.getName().contains("{")) { + return true; + } + if (entryPoint.getMetadata() != null) { + Object path = entryPoint.getMetadata().get("path"); + if (path instanceof String pathValue && pathValue.contains("{")) { + return true; + } + } + return false; + } + } \ No newline at end of file diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizer.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizer.java index 1165e6b..d48ec50 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizer.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizer.java @@ -1064,10 +1064,12 @@ public final class MachineEnumCanonicalizer { return trigger; } String constraint = trigger.getConstraint(); - if (constraint == null || constraint.isBlank()) { - return trigger; + List boundEventLiterals = constraint != null && !constraint.isBlank() + ? extractBoundEventLiteralsFromConstraint(constraint) + : List.of(); + if (boundEventLiterals.size() != 1) { + boundEventLiterals = extractEventLiteralsFromEntryPointPath(entryPoint); } - List boundEventLiterals = extractBoundEventLiteralsFromConstraint(constraint); if (boundEventLiterals.size() != 1) { return trigger; } @@ -1081,7 +1083,9 @@ public final class MachineEnumCanonicalizer { && !entryPoint.getName().toUpperCase().endsWith("/" + literal)) { return trigger; } - String machineTypeLiteral = extractBoundParamLiteralFromConstraint(constraint, "machineType"); + String machineTypeLiteral = constraint != null + ? extractBoundParamLiteralFromConstraint(constraint, "machineType") + : null; if (machineTypeLiteral != null && entryPoint != null && entryPoint.getName() != null && !entryPoint.getName().toUpperCase().contains("/" + machineTypeLiteral.toUpperCase() + "/")) { return trigger; @@ -1122,4 +1126,27 @@ public final class MachineEnumCanonicalizer { } return literals; } + + private static List extractEventLiteralsFromEntryPointPath(EntryPoint entryPoint) { + if (entryPoint == null || entryPoint.getName() == null || entryPoint.getName().isBlank()) { + return List.of(); + } + String rawPath = entryPoint.getName(); + int space = rawPath.indexOf(' '); + String path = space >= 0 ? rawPath.substring(space + 1).trim() : rawPath.trim(); + if (path.isBlank()) { + return List.of(); + } + String[] segments = path.split("/"); + for (int i = segments.length - 1; i >= 0; i--) { + String segment = segments[i]; + if (segment.isBlank() || segment.contains("{")) { + continue; + } + if (segment.matches("[A-Z_][A-Z0-9_]*")) { + return List.of(segment); + } + } + return List.of(); + } } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/ExternalTriggerPolicy.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/ExternalTriggerPolicy.java index 2e6aaa4..6f3c7e6 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/ExternalTriggerPolicy.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/ExternalTriggerPolicy.java @@ -25,6 +25,17 @@ public final class ExternalTriggerPolicy { if (trigger == null) { return false; } + if (entryPoint != null && entryPointHasUnboundPlaceholders(entryPoint) + && MachineEnumCanonicalizer.isDynamicTriggerExpression(trigger.getEvent())) { + String paramName = resolveRestEventParameterName(entryPoint, resolvedEventParamName); + if (paramName != null) { + RestParamBinding binding = findRestParameterBinding(entryPoint, paramName, context); + if (binding != null && binding.pathVariable() && binding.enumType()) { + return false; + } + } + return true; + } if (MachineEnumCanonicalizer.classifyTriggerEvent(trigger.getEvent()) == MachineEnumCanonicalizer.TriggerEventKind.CANONICAL_ENUM) { return false; diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/CallChainPolyNarrowerTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/CallChainPolyNarrowerTest.java index 726168c..bffee9d 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/CallChainPolyNarrowerTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/CallChainPolyNarrowerTest.java @@ -6,11 +6,15 @@ 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; @@ -150,6 +154,176 @@ class CallChainPolyNarrowerTest { .isEqualTo("com.example.OrderEvent.PAY"); } + @Test + void shouldNarrowWidePolyUsingSendEventLiteralInCallerMethod(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + public class OrderController { + CentralEventDispatcher dispatcher; + public void pay() { dispatcher.orderPay(); } + } + class CentralEventDispatcher { + void orderPay() { sendOrderEvent(OrderEvent.PAY); } + void orderShip() { sendOrderEvent(OrderEvent.SHIP); } + private void sendOrderEvent(OrderEvent event) { + StateMachine sm = new StateMachine(); + sm.sendEvent(event); + } + } + enum OrderEvent { PAY, SHIP, CANCEL } + class StateMachine { public void sendEvent(OrderEvent e) {} } + """; + Files.writeString(tempDir.resolve("App.java"), source); + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + + Transition pay = transition("NEW", "PAID", "com.example.OrderEvent.PAY"); + Transition ship = transition("PAID", "SHIPPED", "com.example.OrderEvent.SHIP"); + + CallChain chain = CallChain.builder() + .entryPoint(EntryPoint.builder() + .name("POST /api/orders/pay") + .className("com.example.OrderController") + .methodName("pay") + .build()) + .methodChain(List.of( + "com.example.OrderController.pay", + "com.example.CentralEventDispatcher.orderPay", + "com.example.CentralEventDispatcher.sendOrderEvent", + "com.example.StateMachine.sendEvent")) + .triggerPoint(TriggerPoint.builder() + .ambiguous(true) + .event("event") + .polymorphicEvents(List.of( + "com.example.OrderEvent.PAY", + "com.example.OrderEvent.SHIP", + "com.example.OrderEvent.CANCEL")) + .build()) + .build(); + + AnalysisResult result = AnalysisResult.builder() + .name("com.example.config.OrderStateMachineConfig") + .eventTypeFqn("com.example.OrderEvent") + .stateTypeFqn("com.example.OrderState") + .transitions(List.of(pay, ship)) + .metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build()) + .build(); + + enricher.enrich(result, context, null); + + CallChain linked = result.getMetadata().getCallChains().get(0); + assertThat(linked.getLinkResolution()).isEqualTo(LinkResolution.RESOLVED); + assertThat(linked.getMatchedTransitions()).hasSize(1); + assertThat(linked.getMatchedTransitions().get(0).getEvent()) + .isEqualTo("com.example.OrderEvent.PAY"); + } + + @Test + void shouldNarrowWidePolyUsingRichEventParameterType(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + public class RichOrderController { + CentralEventDispatcher dispatcher; + public void payViaRichEvent() { + dispatcher.orderPayViaRichEvent(new PayRichOrderEvent()); + } + } + class PayRichOrderEvent { + OrderEvent getType() { return OrderEvent.PAY; } + } + class CentralEventDispatcher { + void orderPayViaRichEvent(PayRichOrderEvent event) { + sendOrderEvent(event.getType()); + } + private void sendOrderEvent(OrderEvent event) { + StateMachine sm = new StateMachine(); + sm.sendEvent(event); + } + } + enum OrderEvent { PAY, SHIP, CANCEL } + class StateMachine { public void sendEvent(OrderEvent e) {} } + """; + Files.writeString(tempDir.resolve("App.java"), source); + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + + Transition pay = transition("NEW", "PAID", "com.example.OrderEvent.PAY"); + Transition ship = transition("PAID", "SHIPPED", "com.example.OrderEvent.SHIP"); + + CallChain chain = CallChain.builder() + .entryPoint(EntryPoint.builder() + .name("POST /api/orders/rich/pay") + .className("com.example.RichOrderController") + .methodName("payViaRichEvent") + .build()) + .methodChain(List.of( + "com.example.RichOrderController.payViaRichEvent", + "com.example.CentralEventDispatcher.orderPayViaRichEvent", + "com.example.CentralEventDispatcher.sendOrderEvent", + "com.example.StateMachine.sendEvent")) + .triggerPoint(TriggerPoint.builder() + .ambiguous(true) + .event("event.getType()") + .polymorphicEvents(List.of( + "com.example.OrderEvent.PAY", + "com.example.OrderEvent.SHIP", + "com.example.OrderEvent.CANCEL")) + .build()) + .build(); + + AnalysisResult result = AnalysisResult.builder() + .name("com.example.config.OrderStateMachineConfig") + .eventTypeFqn("com.example.OrderEvent") + .stateTypeFqn("com.example.OrderState") + .transitions(List.of(pay, ship)) + .metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build()) + .build(); + + enricher.enrich(result, context, null); + + CallChain linked = result.getMetadata().getCallChains().get(0); + assertThat(linked.getLinkResolution()).isEqualTo(LinkResolution.RESOLVED); + assertThat(linked.getMatchedTransitions()).hasSize(1); + assertThat(linked.getMatchedTransitions().get(0).getEvent()) + .isEqualTo("com.example.OrderEvent.PAY"); + } + + @Test + void shouldNarrowWidePolyWhenConfigEventTypeFqnIsMissing() { + Transition pay = transition("NEW", "PAID", "com.example.OrderEvent.PAY"); + Transition ship = transition("PAID", "SHIPPED", "com.example.OrderEvent.SHIP"); + + CallChain chain = CallChain.builder() + .entryPoint(EntryPoint.builder() + .name("POST /api/machine/ORDER/transition/PAY") + .className("com.example.MachineController") + .methodName("transition") + .build()) + .triggerPoint(TriggerPoint.builder() + .ambiguous(true) + .event("OrderEvent.valueOf(eventStr)") + .polymorphicEvents(List.of( + "com.example.OrderEvent.PAY", + "com.example.OrderEvent.SHIP", + "com.example.OrderEvent.CANCEL")) + .build()) + .build(); + + AnalysisResult result = AnalysisResult.builder() + .name("com.example.config.OrderStateMachineConfig") + .transitions(List.of(pay, ship)) + .metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build()) + .build(); + + enricher.enrich(result, null, null); + + CallChain linked = result.getMetadata().getCallChains().get(0); + assertThat(linked.getLinkResolution()).isEqualTo(LinkResolution.RESOLVED); + assertThat(linked.getMatchedTransitions()).hasSize(1); + assertThat(linked.getMatchedTransitions().get(0).getEvent()) + .isEqualTo("com.example.OrderEvent.PAY"); + } + @Test void shouldFailClosedWhenWidePolyHasNoEndpointSpecificHint() { Transition pay = transition("NEW", "PAID", "com.example.OrderEvent.PAY"); diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/ExportLinkResolutionAuditTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/ExportLinkResolutionAuditTest.java new file mode 100644 index 0000000..5975d55 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/ExportLinkResolutionAuditTest.java @@ -0,0 +1,121 @@ +package click.kamil.springstatemachineexporter.analysis.service; + +import click.kamil.springstatemachineexporter.exporter.JsonExporter; +import click.kamil.springstatemachineexporter.service.ExportService; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +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.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.StreamSupport; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Audit dedicated and expanded REST endpoints across dispatcher samples: none of the + * known concrete endpoints should remain {@code AMBIGUOUS_WIDEN}. + */ +class ExportLinkResolutionAuditTest { + + @Test + void enterpriseOrderMachineKnownEndpointsShouldNotBeAmbiguousWiden(@TempDir Path tempDir) throws Exception { + JsonNode orderMachine = exportMachine( + tempDir, "state_machines/state_machine_enterprise", "OrderStateMachineConfiguration"); + + List mustResolve = List.of( + "POST /api/machine/order/pay", + "POST /api/machine/order/ship", + "POST /api/machine/ORDER/transition/PAY", + "POST /api/machine/ORDER/transition/SHIP"); + + for (String endpoint : mustResolve) { + JsonNode chain = findChainByEndpoint(orderMachine, endpoint); + assertThat(chain).as("chain for %s", endpoint).isNotNull(); + assertThat(chain.path("linkResolution").asText()) + .as("linkResolution for %s", endpoint) + .isEqualTo("RESOLVED"); + assertThat(chain.path("matchedTransitions").isArray()).isTrue(); + assertThat(chain.path("matchedTransitions")).isNotEmpty(); + } + + StreamSupport.stream(orderMachine.path("metadata").path("callChains").spliterator(), false) + .filter(chain -> chain.path("entryPoint").path("name").asText().contains("/transition/{event}")) + .forEach(chain -> assertThat(chain.path("linkResolution").asText()) + .isIn("UNRESOLVED_EXTERNAL", "AMBIGUOUS_WIDEN", "NO_MATCH")); + } + + @Test + void layeredDispatcherKnownEndpointsShouldNotBeAmbiguousWiden(@TempDir Path tempDir) throws Exception { + JsonNode orderMachine = exportMachine( + tempDir, "state_machines/layered_dispatcher_sample", "StandardOrderStateMachineConfiguration"); + + List mustResolve = List.of( + "POST /api/orders/pay", + "POST /api/orders/ship", + "POST /api/orders/cancel", + "POST /api/orders/rich/pay", + "POST /api/orders/rich/ship", + "POST /api/string-dispatch/orders/pay", + "POST /api/string-dispatch/orders/ship", + "POST /api/commands/order.pay", + "POST /api/commands/order.ship"); + + Map failures = new LinkedHashMap<>(); + for (String endpoint : mustResolve) { + JsonNode chain = findChainByEndpoint(orderMachine, endpoint); + if (chain == null) { + failures.put(endpoint, "missing chain"); + continue; + } + String resolution = chain.path("linkResolution").asText(); + if (!"RESOLVED".equals(resolution)) { + failures.put(endpoint, resolution + " poly=" + + chain.path("triggerPoint").path("polymorphicEvents")); + } + } + assertThat(failures).isEmpty(); + } + + private static JsonNode exportMachine(Path tempDir, String sampleRelativePath, String configBaseName) + throws Exception { + Path sampleRoot = findProjectRoot().resolve(sampleRelativePath); + ExportService exportService = new ExportService(List.of(new JsonExporter())); + exportService.runExporter(sampleRoot, tempDir, List.of("json"), true, List.of(), null, null, + click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, + click.kamil.springstatemachineexporter.exporter.EnumFormat.fn); + return readMachineJson(tempDir, configBaseName, new ObjectMapper()); + } + + private static JsonNode findChainByEndpoint(JsonNode machineJson, String endpointName) { + return StreamSupport.stream(machineJson.path("metadata").path("callChains").spliterator(), false) + .filter(chain -> endpointName.equals(chain.path("entryPoint").path("name").asText())) + .findFirst() + .orElse(null); + } + + private static JsonNode readMachineJson(Path outputDir, String configBaseName, ObjectMapper mapper) + throws Exception { + Path machineDir; + try (var stream = Files.list(outputDir)) { + machineDir = stream + .filter(Files::isDirectory) + .filter(path -> path.getFileName().toString().endsWith(configBaseName)) + .findFirst() + .orElseThrow(() -> new IllegalStateException("No output for " + configBaseName)); + } + return mapper.readTree(machineDir.resolve(machineDir.getFileName() + ".json").toFile()); + } + + private static Path findProjectRoot() { + Path current = Path.of(".").toAbsolutePath(); + while (current != null && !Files.exists(current.resolve("settings.gradle"))) { + current = current.getParent(); + } + return current; + } +}