From dbc22bf1afb34e5eddb0b26404f313f97f3fba60 Mon Sep 17 00:00:00 2001 From: Kamil Patryk Kozakowski Date: Tue, 14 Jul 2026 20:22:32 +0200 Subject: [PATCH] Fix zero transition links when config event types are unresolved. Derive machine enum types from transition events for poly narrowing, scan sendEvent literals in caller methods, and keep generic REST templates external without blocking dedicated endpoints. Co-authored-by: Cursor --- .../enricher/CallChainLinkPolicy.java | 88 +++++++++ .../enricher/CallChainPolyNarrower.java | 135 +++++++++++++- .../enricher/TransitionLinkerEnricher.java | 72 ++++++-- .../resolver/MachineEnumCanonicalizer.java | 35 +++- .../service/ExternalTriggerPolicy.java | 11 ++ .../enricher/CallChainPolyNarrowerTest.java | 174 ++++++++++++++++++ .../ExportLinkResolutionAuditTest.java | 121 ++++++++++++ 7 files changed, 605 insertions(+), 31 deletions(-) create mode 100644 state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/ExportLinkResolutionAuditTest.java 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; + } +}