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 <cursoragent@cursor.com>
This commit is contained in:
2026-07-14 20:22:32 +02:00
parent 758d433726
commit dbc22bf1af
7 changed files with 605 additions and 31 deletions

View File

@@ -6,8 +6,11 @@ import click.kamil.springstatemachineexporter.analysis.model.LinkResolution;
import click.kamil.springstatemachineexporter.analysis.model.MatchedTransition; import click.kamil.springstatemachineexporter.analysis.model.MatchedTransition;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer; 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.analysis.service.ExternalTriggerPolicy;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; 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.ArrayList;
import java.util.List; import java.util.List;
@@ -20,6 +23,91 @@ public final class CallChainLinkPolicy {
private 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<Transition> 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<Transition> 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<Transition> 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<Transition> 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) { public static boolean shouldFailClosedOnAmbiguousCallGraphWiden(TriggerPoint trigger) {
return shouldFailClosedOnAmbiguousCallGraphWiden(trigger, null, null); return shouldFailClosedOnAmbiguousCallGraphWiden(trigger, null, null);
} }

View File

@@ -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.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint; import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; 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.MachineEnumCanonicalizer;
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver; import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.model.Transition; 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.ArrayList;
import java.util.HashMap; import java.util.HashMap;
@@ -33,11 +42,16 @@ public final class CallChainPolyNarrower {
private static final Pattern QUOTED_EQUALS_PARAM = Pattern.compile( private static final Pattern QUOTED_EQUALS_PARAM = Pattern.compile(
"\"([^\"]+)\"\\.equalsIgnoreCase\\((\\w+)\\)"); "\"([^\"]+)\"\\.equalsIgnoreCase\\((\\w+)\\)");
private static final Pattern PARAM_EQUALS_LITERAL = Pattern.compile(
"(\\w+)\\.equals(?:IgnoreCase)?\\(\"([^\"]+)\"\\)");
private static final Pattern ENUM_EQ = Pattern.compile( private static final Pattern ENUM_EQ = Pattern.compile(
"([A-Za-z_][\\w.]*)\\s*==\\s*([A-Za-z_][\\w.]*)"); "([A-Za-z_][\\w.]*)\\s*==\\s*([A-Za-z_][\\w.]*)");
private static final Pattern ROUTE_LITERAL = Pattern.compile( private static final Pattern ROUTE_LITERAL = Pattern.compile(
"route\\(\\s*\"([^\"]+)\"\\s*,\\s*\"([^\"]+)\"\\s*\\)"); "route\\(\\s*\"([^\"]+)\"\\s*,\\s*\"([^\"]+)\"\\s*\\)");
private static final Set<String> FIRE_METHOD_NAMES = Set.of(
"sendEvent", "fire", "fireEvent", "dispatchEvent", "send", "trigger");
private CallChainPolyNarrower() { private CallChainPolyNarrower() {
} }
@@ -47,7 +61,17 @@ public final class CallChainPolyNarrower {
StateMachineTypeResolver.MachineTypes machineTypes, StateMachineTypeResolver.MachineTypes machineTypes,
List<Transition> machineTransitions, List<Transition> machineTransitions,
CodebaseContext context) { 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<Transition> machineTransitions,
CodebaseContext context) {
if (trigger == null) {
return trigger; return trigger;
} }
List<String> poly = trigger.getPolymorphicEvents(); List<String> poly = trigger.getPolymorphicEvents();
@@ -55,7 +79,15 @@ public final class CallChainPolyNarrower {
return trigger; 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<String> configuredConstants = configuredEventConstants(machineTransitions, eventTypeFqn, context); Set<String> configuredConstants = configuredEventConstants(machineTransitions, eventTypeFqn, context);
if (configuredConstants.isEmpty()) { if (configuredConstants.isEmpty()) {
return trigger; return trigger;
@@ -94,6 +126,7 @@ public final class CallChainPolyNarrower {
collectFromConstraint(trigger.getConstraint(), configuredConstants, votes); collectFromConstraint(trigger.getConstraint(), configuredConstants, votes);
if (chain != null) { if (chain != null) {
collectFromMethodChain(chain.getMethodChain(), configuredConstants, votes); collectFromMethodChain(chain.getMethodChain(), configuredConstants, votes);
collectFromSendEventLiterals(chain.getMethodChain(), configuredConstants, context, votes);
collectFromPathEstimator(chain, 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(1), configuredConstants, votes, MEDIUM);
addEnumReference(enumEq.group(2), 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( private static void collectFromMethodChain(
@@ -196,6 +238,69 @@ public final class CallChainPolyNarrower {
for (String token : splitCamelCase(methodName)) { for (String token : splitCamelCase(methodName)) {
addPathToken(token, configuredConstants, votes, WEAK); 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<String> methodChain,
Set<String> 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; return null;
} }
final int winningScore = bestScore; final int winningScore = bestScore;
long contenders = scores.entrySet().stream() List<String> tied = new ArrayList<>();
.filter(e -> configured.contains(e.getKey())) for (String constant : configured) {
.filter(e -> e.getValue() == winningScore) if (scores.getOrDefault(constant, 0) == winningScore) {
.count(); tied.add(constant);
return contenders == 1 ? best : null; }
}
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<String> configured, int minWeight) { private String uniqueAboveWeight(Set<String> configured, int minWeight) {

View File

@@ -49,6 +49,15 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
result.getStateTypeFqn(), result.getStateTypeFqn(),
result.getEventTypeFqn(), result.getEventTypeFqn(),
context); 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()) { for (CallChain chain : result.getMetadata().getCallChains()) {
TriggerPoint tp = chain.getTriggerPoint(); TriggerPoint tp = chain.getTriggerPoint();
@@ -58,33 +67,40 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
} }
tp = MachineEnumCanonicalizer.expandBoundValueOfFromConstraints( tp = MachineEnumCanonicalizer.expandBoundValueOfFromConstraints(
tp, machineTypes, context, chain.getEntryPoint()); tp, effectiveMachineTypes, context, chain.getEntryPoint());
tp = MachineEnumCanonicalizer.ensureCallChainPolymorphicEvents( tp = MachineEnumCanonicalizer.ensureCallChainPolymorphicEvents(
tp, machineTypes, context, stateMachineTransitions, true); tp, effectiveMachineTypes, context, stateMachineTransitions, true);
tp = CallChainPolyNarrower.narrowForCallChain( tp = CallChainPolyNarrower.narrowForCallChain(
tp, chain, machineTypes, stateMachineTransitions, context); tp, chain, effectiveMachineTypes, result.getEventTypeFqn(), stateMachineTransitions, context);
tp = MachineEnumCanonicalizer.canonicalizeTriggerLabelsForLinking(tp, machineTypes, context); tp = MachineEnumCanonicalizer.canonicalizeTriggerLabelsForLinking(tp, effectiveMachineTypes, context);
tp = CallChainLinkPolicy.applyRestExternalPolicy(tp, chain, context); tp = CallChainLinkPolicy.applyRestExternalPolicy(tp, chain, context);
if (machineTypes != null) {
tp = tp.toBuilder() tp = tp.toBuilder()
.eventTypeFqn(tp.getEventTypeFqn() != null .eventTypeFqn(tp.getEventTypeFqn() != null
? tp.getEventTypeFqn() ? tp.getEventTypeFqn()
: machineTypes.eventTypeFqn()) : effectiveEventTypeFqn)
.stateTypeFqn(tp.getStateTypeFqn() != null .stateTypeFqn(tp.getStateTypeFqn() != null
? tp.getStateTypeFqn() ? tp.getStateTypeFqn()
: machineTypes.stateTypeFqn()) : effectiveMachineTypes.stateTypeFqn())
.build(); .build();
}
if (LifecycleTriggerMarkers.isLifecycle(tp.getEvent())) { if (LifecycleTriggerMarkers.isLifecycle(tp.getEvent())) {
updatedChains.add(chain); updatedChains.add(chain);
continue; 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; String triggerSource = tp.getSourceState() != null ? simplifySourceState(tp.getSourceState()) : null;
if (CallChainLinkPolicy.shouldFailClosedOnAmbiguousCallGraphWiden( if (CallChainLinkPolicy.shouldFailClosedOnAmbiguousCallGraphWiden(
tp, tp,
machineTypes != null ? machineTypes.eventTypeFqn() : null, effectiveEventTypeFqn,
context)) { context)) {
updatedChains.add(chain.toBuilder() updatedChains.add(chain.toBuilder()
.triggerPoint(tp) .triggerPoint(tp)
@@ -104,7 +120,7 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
if (t.getEvent() != null && matchingEngine.matches(t.getEvent(), tp) if (t.getEvent() != null && matchingEngine.matches(t.getEvent(), tp)
&& isRoutedToCorrectMachine( && isRoutedToCorrectMachine(
chain, result.getName(), context, stateMachineTransitions, tp, chain, result.getName(), context, stateMachineTransitions, tp,
machineTypes != null ? machineTypes.eventTypeFqn() : null) effectiveEventTypeFqn)
&& isConstraintCompatible(tp.getConstraint(), result.getName())) { && isConstraintCompatible(tp.getConstraint(), result.getName())) {
String smEventForLink = canonicalEvent(t.getEvent()); String smEventForLink = canonicalEvent(t.getEvent());
for (State smSourceState : t.getSourceStates()) { for (State smSourceState : t.getSourceStates()) {
@@ -125,7 +141,7 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
} else if (sourcesByEvent.values().stream().anyMatch(sources -> sources.size() > 1)) { } else if (sourcesByEvent.values().stream().anyMatch(sources -> sources.size() > 1)) {
ambiguousSource = !CallChainLinkPolicy.isEndpointNarrowPolyEvidence( ambiguousSource = !CallChainLinkPolicy.isEndpointNarrowPolyEvidence(
tp, tp,
machineTypes != null ? machineTypes.eventTypeFqn() : null, effectiveEventTypeFqn,
context); context);
} else { } else {
Set<String> allDistinctSources = new LinkedHashSet<>(); Set<String> allDistinctSources = new LinkedHashSet<>();
@@ -157,7 +173,7 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
.build(); .build();
if (isRoutedToCorrectMachine( if (isRoutedToCorrectMachine(
chain, result.getName(), context, stateMachineTransitions, tp, chain, result.getName(), context, stateMachineTransitions, tp,
machineTypes != null ? machineTypes.eventTypeFqn() : null) effectiveEventTypeFqn)
&& isConstraintCompatible(tp.getConstraint(), result.getName())) { && isConstraintCompatible(tp.getConstraint(), result.getName())) {
matched.add(mt); matched.add(mt);
} }
@@ -171,7 +187,7 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
.build(); .build();
if (isRoutedToCorrectMachine( if (isRoutedToCorrectMachine(
chain, result.getName(), context, stateMachineTransitions, tp, chain, result.getName(), context, stateMachineTransitions, tp,
machineTypes != null ? machineTypes.eventTypeFqn() : null) effectiveEventTypeFqn)
&& isConstraintCompatible(tp.getConstraint(), result.getName())) { && isConstraintCompatible(tp.getConstraint(), result.getName())) {
matched.add(mt); matched.add(mt);
} }
@@ -191,7 +207,7 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
tp, tp,
matched, matched,
ambiguousSource, ambiguousSource,
machineTypes != null ? machineTypes.eventTypeFqn() : null, effectiveEventTypeFqn,
context); context);
if (!matched.isEmpty()) { if (!matched.isEmpty()) {
@@ -316,4 +332,20 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
return event.fullIdentifier() != null ? event.fullIdentifier() : event.rawName(); 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;
}
} }

View File

@@ -1064,10 +1064,12 @@ public final class MachineEnumCanonicalizer {
return trigger; return trigger;
} }
String constraint = trigger.getConstraint(); String constraint = trigger.getConstraint();
if (constraint == null || constraint.isBlank()) { List<String> boundEventLiterals = constraint != null && !constraint.isBlank()
return trigger; ? extractBoundEventLiteralsFromConstraint(constraint)
: List.of();
if (boundEventLiterals.size() != 1) {
boundEventLiterals = extractEventLiteralsFromEntryPointPath(entryPoint);
} }
List<String> boundEventLiterals = extractBoundEventLiteralsFromConstraint(constraint);
if (boundEventLiterals.size() != 1) { if (boundEventLiterals.size() != 1) {
return trigger; return trigger;
} }
@@ -1081,7 +1083,9 @@ public final class MachineEnumCanonicalizer {
&& !entryPoint.getName().toUpperCase().endsWith("/" + literal)) { && !entryPoint.getName().toUpperCase().endsWith("/" + literal)) {
return trigger; return trigger;
} }
String machineTypeLiteral = extractBoundParamLiteralFromConstraint(constraint, "machineType"); String machineTypeLiteral = constraint != null
? extractBoundParamLiteralFromConstraint(constraint, "machineType")
: null;
if (machineTypeLiteral != null && entryPoint != null && entryPoint.getName() != null if (machineTypeLiteral != null && entryPoint != null && entryPoint.getName() != null
&& !entryPoint.getName().toUpperCase().contains("/" + machineTypeLiteral.toUpperCase() + "/")) { && !entryPoint.getName().toUpperCase().contains("/" + machineTypeLiteral.toUpperCase() + "/")) {
return trigger; return trigger;
@@ -1122,4 +1126,27 @@ public final class MachineEnumCanonicalizer {
} }
return literals; return literals;
} }
private static List<String> 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();
}
} }

View File

@@ -25,6 +25,17 @@ public final class ExternalTriggerPolicy {
if (trigger == null) { if (trigger == null) {
return false; 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()) if (MachineEnumCanonicalizer.classifyTriggerEvent(trigger.getEvent())
== MachineEnumCanonicalizer.TriggerEventKind.CANONICAL_ENUM) { == MachineEnumCanonicalizer.TriggerEventKind.CANONICAL_ENUM) {
return false; return false;

View File

@@ -6,11 +6,15 @@ import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint; import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import click.kamil.springstatemachineexporter.analysis.model.LinkResolution; import click.kamil.springstatemachineexporter.analysis.model.LinkResolution;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; 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.Event;
import click.kamil.springstatemachineexporter.model.State; import click.kamil.springstatemachineexporter.model.State;
import click.kamil.springstatemachineexporter.model.Transition; import click.kamil.springstatemachineexporter.model.Transition;
import org.junit.jupiter.api.Test; 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 java.util.List;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
@@ -150,6 +154,176 @@ class CallChainPolyNarrowerTest {
.isEqualTo("com.example.OrderEvent.PAY"); .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 @Test
void shouldFailClosedWhenWidePolyHasNoEndpointSpecificHint() { void shouldFailClosedWhenWidePolyHasNoEndpointSpecificHint() {
Transition pay = transition("NEW", "PAID", "com.example.OrderEvent.PAY"); Transition pay = transition("NEW", "PAID", "com.example.OrderEvent.PAY");

View File

@@ -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<String> 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<String> 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<String, String> 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;
}
}