Narrow wide polymorphic events per call chain before linking.
Use multi-source endpoint evidence so shared dispatchers resolve to one event per endpoint without over-linking or fail-closed AMBIGUOUS_WIDEN. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,422 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.enricher;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.enricher.path.SymbolicPathValueEstimator;
|
||||
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.MachineEnumCanonicalizer;
|
||||
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import click.kamil.springstatemachineexporter.model.Transition;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Narrows wide call-graph polymorphic widens to endpoint-specific enum constants.
|
||||
*
|
||||
* <p>Uses multiple independent evidence sources (REST path, command keys, constraints, static trigger
|
||||
* events, method-chain literals, camelCase method names) and only narrows when evidence converges.
|
||||
*/
|
||||
public final class CallChainPolyNarrower {
|
||||
|
||||
private static final int STRONG = 3;
|
||||
private static final int MEDIUM = 2;
|
||||
private static final int WEAK = 1;
|
||||
|
||||
private static final Pattern QUOTED_EQUALS_PARAM = Pattern.compile(
|
||||
"\"([^\"]+)\"\\.equalsIgnoreCase\\((\\w+)\\)");
|
||||
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 CallChainPolyNarrower() {
|
||||
}
|
||||
|
||||
public static TriggerPoint narrowForCallChain(
|
||||
TriggerPoint trigger,
|
||||
CallChain chain,
|
||||
StateMachineTypeResolver.MachineTypes machineTypes,
|
||||
List<Transition> machineTransitions,
|
||||
CodebaseContext context) {
|
||||
if (trigger == null || machineTypes == null || machineTypes.eventTypeFqn() == null) {
|
||||
return trigger;
|
||||
}
|
||||
List<String> poly = trigger.getPolymorphicEvents();
|
||||
if (poly == null || poly.size() <= 1) {
|
||||
return trigger;
|
||||
}
|
||||
|
||||
String eventTypeFqn = machineTypes.eventTypeFqn();
|
||||
Set<String> configuredConstants = configuredEventConstants(machineTransitions, eventTypeFqn, context);
|
||||
if (configuredConstants.isEmpty()) {
|
||||
return trigger;
|
||||
}
|
||||
|
||||
HintVotes votes = new HintVotes();
|
||||
collectHints(trigger, chain, configuredConstants, context, votes);
|
||||
|
||||
String chosen = votes.choose(configuredConstants);
|
||||
if (chosen == null) {
|
||||
return trigger;
|
||||
}
|
||||
|
||||
List<String> narrowed = filterPolyToConstant(poly, chosen, eventTypeFqn, context);
|
||||
if (narrowed.size() == 1) {
|
||||
return trigger.toBuilder()
|
||||
.polymorphicEvents(narrowed)
|
||||
.ambiguous(false)
|
||||
.build();
|
||||
}
|
||||
|
||||
return trigger.toBuilder()
|
||||
.polymorphicEvents(List.of(eventTypeFqn + "." + chosen))
|
||||
.ambiguous(false)
|
||||
.build();
|
||||
}
|
||||
|
||||
private static void collectHints(
|
||||
TriggerPoint trigger,
|
||||
CallChain chain,
|
||||
Set<String> configuredConstants,
|
||||
CodebaseContext context,
|
||||
HintVotes votes) {
|
||||
collectFromStaticTriggerEvent(trigger, configuredConstants, votes);
|
||||
collectFromEntryPoint(chain != null ? chain.getEntryPoint() : null, configuredConstants, votes);
|
||||
collectFromConstraint(trigger.getConstraint(), configuredConstants, votes);
|
||||
if (chain != null) {
|
||||
collectFromMethodChain(chain.getMethodChain(), configuredConstants, votes);
|
||||
collectFromPathEstimator(chain, configuredConstants, context, votes);
|
||||
}
|
||||
}
|
||||
|
||||
private static void collectFromStaticTriggerEvent(
|
||||
TriggerPoint trigger,
|
||||
Set<String> configuredConstants,
|
||||
HintVotes votes) {
|
||||
String event = trigger.getEvent();
|
||||
if (event == null || event.isBlank()) {
|
||||
return;
|
||||
}
|
||||
if (MachineEnumCanonicalizer.isDynamicTriggerExpression(event)) {
|
||||
return;
|
||||
}
|
||||
addEnumReference(event, configuredConstants, votes, STRONG);
|
||||
}
|
||||
|
||||
private static void collectFromEntryPoint(
|
||||
EntryPoint entryPoint,
|
||||
Set<String> configuredConstants,
|
||||
HintVotes votes) {
|
||||
if (entryPoint == null) {
|
||||
return;
|
||||
}
|
||||
if (entryPoint.getName() != null) {
|
||||
collectFromPath(entryPoint.getName(), configuredConstants, votes);
|
||||
}
|
||||
if (entryPoint.getMetadata() != null) {
|
||||
Object path = entryPoint.getMetadata().get("path");
|
||||
if (path instanceof String pathValue) {
|
||||
collectFromPath(pathValue, configuredConstants, votes);
|
||||
}
|
||||
}
|
||||
if (entryPoint.getMethodName() != null) {
|
||||
addUniqueIdentifierMatch(entryPoint.getMethodName(), configuredConstants, votes, WEAK);
|
||||
}
|
||||
}
|
||||
|
||||
private static void collectFromPath(String rawPath, Set<String> configuredConstants, HintVotes votes) {
|
||||
if (rawPath == null || rawPath.isBlank()) {
|
||||
return;
|
||||
}
|
||||
int space = rawPath.indexOf(' ');
|
||||
String path = space >= 0 ? rawPath.substring(space + 1).trim() : rawPath.trim();
|
||||
if (path.isBlank()) {
|
||||
return;
|
||||
}
|
||||
for (String segment : path.split("/")) {
|
||||
if (segment.isBlank() || segment.contains("{")) {
|
||||
continue;
|
||||
}
|
||||
addPathToken(segment, configuredConstants, votes, STRONG);
|
||||
}
|
||||
}
|
||||
|
||||
private static void collectFromConstraint(
|
||||
String constraint,
|
||||
Set<String> configuredConstants,
|
||||
HintVotes votes) {
|
||||
if (constraint == null || constraint.isBlank()) {
|
||||
return;
|
||||
}
|
||||
Matcher quoted = QUOTED_EQUALS_PARAM.matcher(constraint);
|
||||
while (quoted.find()) {
|
||||
String literal = quoted.group(1);
|
||||
String param = quoted.group(2);
|
||||
if ("event".equals(param) || "eventString".equals(param) || "actionKey".equals(param)
|
||||
|| "action".equals(param)) {
|
||||
addPathToken(literal, configuredConstants, votes, MEDIUM);
|
||||
}
|
||||
if ("commandKey".equals(param) || literal.contains(".")) {
|
||||
addPathToken(literal, configuredConstants, votes, MEDIUM);
|
||||
}
|
||||
}
|
||||
Matcher route = ROUTE_LITERAL.matcher(constraint);
|
||||
while (route.find()) {
|
||||
addPathToken(route.group(2), configuredConstants, votes, MEDIUM);
|
||||
}
|
||||
Matcher enumEq = ENUM_EQ.matcher(constraint);
|
||||
while (enumEq.find()) {
|
||||
addEnumReference(enumEq.group(1), configuredConstants, votes, MEDIUM);
|
||||
addEnumReference(enumEq.group(2), configuredConstants, votes, MEDIUM);
|
||||
}
|
||||
}
|
||||
|
||||
private static void collectFromMethodChain(
|
||||
List<String> methodChain,
|
||||
Set<String> configuredConstants,
|
||||
HintVotes votes) {
|
||||
if (methodChain == null) {
|
||||
return;
|
||||
}
|
||||
for (String methodFqn : methodChain) {
|
||||
if (methodFqn == null || !methodFqn.contains(".")) {
|
||||
continue;
|
||||
}
|
||||
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
|
||||
addUniqueIdentifierMatch(methodName, configuredConstants, votes, WEAK);
|
||||
for (String token : splitCamelCase(methodName)) {
|
||||
addPathToken(token, configuredConstants, votes, WEAK);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void collectFromPathEstimator(
|
||||
CallChain chain,
|
||||
Set<String> configuredConstants,
|
||||
CodebaseContext context,
|
||||
HintVotes votes) {
|
||||
if (context == null) {
|
||||
return;
|
||||
}
|
||||
List<String> estimated = new SymbolicPathValueEstimator().estimatePossibleValues(chain, context);
|
||||
if (estimated == null || estimated.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
int weight = estimated.size() == 1 ? MEDIUM : WEAK;
|
||||
for (String value : estimated) {
|
||||
addEnumReference(value, configuredConstants, votes, weight);
|
||||
}
|
||||
}
|
||||
|
||||
private static void addPathToken(
|
||||
String token,
|
||||
Set<String> configuredConstants,
|
||||
HintVotes votes,
|
||||
int weight) {
|
||||
if (token == null || token.isBlank()) {
|
||||
return;
|
||||
}
|
||||
addPathTokenVariants(token, configuredConstants, votes, weight);
|
||||
if (token.contains(".")) {
|
||||
addPathTokenVariants(token.substring(token.lastIndexOf('.') + 1), configuredConstants, votes, weight);
|
||||
}
|
||||
}
|
||||
|
||||
private static void addPathTokenVariants(
|
||||
String token,
|
||||
Set<String> configuredConstants,
|
||||
HintVotes votes,
|
||||
int weight) {
|
||||
maybeVoteConstant(votes, normalizeToken(token), configuredConstants, weight);
|
||||
for (String part : token.split("[-_.]")) {
|
||||
if (!part.isBlank()) {
|
||||
maybeVoteConstant(votes, normalizeToken(part), configuredConstants, weight);
|
||||
}
|
||||
}
|
||||
if (token.toUpperCase(Locale.ROOT).contains("_")) {
|
||||
String tail = token.substring(token.lastIndexOf('_') + 1);
|
||||
maybeVoteConstant(votes, normalizeToken(tail), configuredConstants, weight);
|
||||
}
|
||||
}
|
||||
|
||||
private static void addEnumReference(
|
||||
String reference,
|
||||
Set<String> configuredConstants,
|
||||
HintVotes votes,
|
||||
int weight) {
|
||||
if (reference == null || reference.isBlank()) {
|
||||
return;
|
||||
}
|
||||
maybeVoteConstant(votes, constantName(reference), configuredConstants, weight);
|
||||
if (reference.contains(".")) {
|
||||
maybeVoteConstant(votes, constantName(reference), configuredConstants, weight);
|
||||
}
|
||||
String upper = reference.toUpperCase(Locale.ROOT);
|
||||
if (upper.contains("_")) {
|
||||
maybeVoteConstant(votes, upper.substring(upper.lastIndexOf('_') + 1), configuredConstants, weight);
|
||||
}
|
||||
}
|
||||
|
||||
private static void addUniqueIdentifierMatch(
|
||||
String identifier,
|
||||
Set<String> configuredConstants,
|
||||
HintVotes votes,
|
||||
int weight) {
|
||||
if (identifier == null || identifier.isBlank()) {
|
||||
return;
|
||||
}
|
||||
String upper = identifier.toUpperCase(Locale.ROOT);
|
||||
List<String> matched = new ArrayList<>();
|
||||
for (String constant : configuredConstants) {
|
||||
if (upper.contains(constant)) {
|
||||
matched.add(constant);
|
||||
}
|
||||
}
|
||||
if (matched.size() == 1) {
|
||||
votes.add(matched.get(0), weight);
|
||||
}
|
||||
}
|
||||
|
||||
private static void maybeVoteConstant(
|
||||
HintVotes votes,
|
||||
String candidate,
|
||||
Set<String> configuredConstants,
|
||||
int weight) {
|
||||
if (candidate == null || candidate.isBlank()) {
|
||||
return;
|
||||
}
|
||||
String normalized = candidate.toUpperCase(Locale.ROOT);
|
||||
if (configuredConstants.contains(normalized)) {
|
||||
votes.add(normalized, weight);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<String> filterPolyToConstant(
|
||||
List<String> poly,
|
||||
String constant,
|
||||
String eventTypeFqn,
|
||||
CodebaseContext context) {
|
||||
List<String> narrowed = new ArrayList<>();
|
||||
for (String candidate : poly) {
|
||||
if (candidate == null || candidate.startsWith("<SYMBOLIC:") || candidate.startsWith("ENUM_SET:")) {
|
||||
continue;
|
||||
}
|
||||
if (!constant.equalsIgnoreCase(constantName(candidate))) {
|
||||
continue;
|
||||
}
|
||||
String canonical = candidate.contains(".")
|
||||
? MachineEnumCanonicalizer.canonicalizeLabel(candidate, eventTypeFqn, context)
|
||||
: eventTypeFqn + "." + constant;
|
||||
if (!narrowed.contains(canonical)) {
|
||||
narrowed.add(canonical);
|
||||
}
|
||||
}
|
||||
return narrowed;
|
||||
}
|
||||
|
||||
private static String normalizeToken(String token) {
|
||||
return token.replace('-', '_').toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private static List<String> splitCamelCase(String identifier) {
|
||||
if (identifier == null || identifier.isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
List<String> tokens = new ArrayList<>();
|
||||
StringBuilder current = new StringBuilder();
|
||||
for (int i = 0; i < identifier.length(); i++) {
|
||||
char ch = identifier.charAt(i);
|
||||
if (Character.isUpperCase(ch) && !current.isEmpty()) {
|
||||
tokens.add(current.toString());
|
||||
current.setLength(0);
|
||||
}
|
||||
current.append(ch);
|
||||
}
|
||||
if (!current.isEmpty()) {
|
||||
tokens.add(current.toString());
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
private static Set<String> configuredEventConstants(
|
||||
List<Transition> transitions,
|
||||
String eventTypeFqn,
|
||||
CodebaseContext context) {
|
||||
Set<String> constants = new LinkedHashSet<>();
|
||||
for (String event : MachineEnumCanonicalizer.polymorphicEventsFromTransitions(
|
||||
transitions, eventTypeFqn, context)) {
|
||||
constants.add(constantName(event).toUpperCase(Locale.ROOT));
|
||||
}
|
||||
return constants;
|
||||
}
|
||||
|
||||
private static String constantName(String ref) {
|
||||
int dot = ref.lastIndexOf('.');
|
||||
return (dot >= 0 ? ref.substring(dot + 1) : ref).toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private static final class HintVotes {
|
||||
private final Map<String, Integer> scores = new HashMap<>();
|
||||
private final Map<String, Integer> maxWeight = new HashMap<>();
|
||||
|
||||
void add(String constant, int weight) {
|
||||
if (constant == null || constant.isBlank()) {
|
||||
return;
|
||||
}
|
||||
String key = constant.toUpperCase(Locale.ROOT);
|
||||
scores.merge(key, weight, Integer::sum);
|
||||
maxWeight.merge(key, weight, Math::max);
|
||||
}
|
||||
|
||||
String choose(Set<String> configured) {
|
||||
String strongUnique = uniqueAboveWeight(configured, STRONG);
|
||||
if (strongUnique != null) {
|
||||
return strongUnique;
|
||||
}
|
||||
String mediumUnique = uniqueAboveWeight(configured, MEDIUM);
|
||||
if (mediumUnique != null) {
|
||||
return mediumUnique;
|
||||
}
|
||||
String best = null;
|
||||
int bestScore = 0;
|
||||
for (String constant : configured) {
|
||||
int score = scores.getOrDefault(constant, 0);
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
best = constant;
|
||||
}
|
||||
}
|
||||
if (best == null || bestScore < MEDIUM) {
|
||||
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;
|
||||
}
|
||||
|
||||
private String uniqueAboveWeight(Set<String> configured, int minWeight) {
|
||||
String chosen = null;
|
||||
for (String constant : configured) {
|
||||
if (maxWeight.getOrDefault(constant, 0) >= minWeight) {
|
||||
if (chosen != null) {
|
||||
return null;
|
||||
}
|
||||
chosen = constant;
|
||||
}
|
||||
}
|
||||
return chosen;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,8 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
|
||||
tp, machineTypes, context, chain.getEntryPoint());
|
||||
tp = MachineEnumCanonicalizer.ensureCallChainPolymorphicEvents(
|
||||
tp, machineTypes, context, stateMachineTransitions, true);
|
||||
tp = CallChainPolyNarrower.narrowForCallChain(
|
||||
tp, chain, machineTypes, stateMachineTransitions, context);
|
||||
tp = MachineEnumCanonicalizer.canonicalizeTriggerLabelsForLinking(tp, machineTypes, context);
|
||||
tp = CallChainLinkPolicy.applyRestExternalPolicy(tp, chain, context);
|
||||
if (machineTypes != null) {
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.enricher;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.LinkResolution;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||
import click.kamil.springstatemachineexporter.model.Event;
|
||||
import click.kamil.springstatemachineexporter.model.State;
|
||||
import click.kamil.springstatemachineexporter.model.Transition;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class CallChainPolyNarrowerTest {
|
||||
|
||||
private final TransitionLinkerEnricher enricher = new TransitionLinkerEnricher();
|
||||
|
||||
@Test
|
||||
void shouldNarrowWidePolyUsingExpandedRestPathSegment() {
|
||||
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())
|
||||
.methodChain(List.of(
|
||||
"com.example.MachineController.transition",
|
||||
"com.example.StateMachineDispatcher.fireOrder",
|
||||
"com.example.StateMachine.send"))
|
||||
.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")
|
||||
.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, 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");
|
||||
assertThat(linked.getTriggerPoint().getPolymorphicEvents())
|
||||
.containsExactly("com.example.OrderEvent.PAY");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNarrowWidePolyUsingRichDispatcherMethodName() {
|
||||
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.CommandGateway.payOrderViaRichEvent",
|
||||
"com.example.CentralEventDispatcher.orderPayViaRichEvent",
|
||||
"com.example.CentralEventDispatcher.sendOrderEvent"))
|
||||
.triggerPoint(TriggerPoint.builder()
|
||||
.ambiguous(true)
|
||||
.event("richEvent.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, 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 shouldNarrowUsingDottedCommandKeyPath() {
|
||||
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/commands/order.pay")
|
||||
.className("com.example.GenericCommandController")
|
||||
.methodName("execute")
|
||||
.build())
|
||||
.methodChain(List.of(
|
||||
"com.example.GenericCommandController.execute",
|
||||
"com.example.CommandGateway.executeViaMapper",
|
||||
"com.example.CentralEventDispatcher.orderPay",
|
||||
"com.example.CentralEventDispatcher.sendOrderEvent"))
|
||||
.triggerPoint(TriggerPoint.builder()
|
||||
.ambiguous(true)
|
||||
.event("OrderEvent.valueOf(actionKey)")
|
||||
.constraint("\"order.pay\".equalsIgnoreCase(commandKey) && command == ORDER_PAY")
|
||||
.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, 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");
|
||||
Transition ship = transition("PAID", "SHIPPED", "com.example.OrderEvent.SHIP");
|
||||
|
||||
CallChain chain = CallChain.builder()
|
||||
.entryPoint(EntryPoint.builder()
|
||||
.name("POST /api/machine/{machineType}/transition/{event}")
|
||||
.className("com.example.MachineController")
|
||||
.methodName("transition")
|
||||
.build())
|
||||
.triggerPoint(TriggerPoint.builder()
|
||||
.ambiguous(true)
|
||||
.external(true)
|
||||
.event("OrderEvent.valueOf(eventStr)")
|
||||
.polymorphicEvents(List.of(
|
||||
"com.example.OrderEvent.PAY",
|
||||
"com.example.OrderEvent.SHIP"))
|
||||
.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, null, null);
|
||||
|
||||
CallChain linked = result.getMetadata().getCallChains().get(0);
|
||||
assertThat(linked.getMatchedTransitions()).isNullOrEmpty();
|
||||
assertThat(linked.getLinkResolution()).isIn(
|
||||
LinkResolution.AMBIGUOUS_WIDEN,
|
||||
LinkResolution.UNRESOLVED_EXTERNAL);
|
||||
}
|
||||
|
||||
private static Transition transition(String source, String target, String eventFqn) {
|
||||
Transition transition = new Transition();
|
||||
transition.setSourceStates(List.of(State.of(source, "com.example.OrderState." + source)));
|
||||
transition.setTargetStates(List.of(State.of(target, "com.example.OrderState." + target)));
|
||||
transition.setEvent(Event.of(eventFqn.substring(eventFqn.lastIndexOf('.') + 1), eventFqn));
|
||||
return transition;
|
||||
}
|
||||
}
|
||||
@@ -67,14 +67,12 @@ class DispatcherPolyCeilingPipelineTest {
|
||||
CallChain linked = linkChain(
|
||||
context, bloatedChain, MACHINE_CONFIG, EVENT_TYPE, STATE_TYPE, pay, ship);
|
||||
|
||||
assertPolyWithinTransitions(linked, 2);
|
||||
assertPolyCappedToTransitionEvents(linked, EVENT_TYPE, pay, ship);
|
||||
assertThat(linked.getTriggerPoint().getPolymorphicEvents())
|
||||
.containsExactlyInAnyOrder(EVENT_TYPE + ".PAY", EVENT_TYPE + ".SHIP");
|
||||
assertMatchedWithinTransitions(linked, 2);
|
||||
.containsExactly(EVENT_TYPE + ".PAY");
|
||||
assertMatchedWithinTransitions(linked, 1);
|
||||
assertThat(linked.getMatchedTransitions())
|
||||
.extracting(MatchedTransition::getEvent)
|
||||
.containsExactlyInAnyOrder(EVENT_TYPE + ".PAY", EVENT_TYPE + ".SHIP");
|
||||
.containsExactly(EVENT_TYPE + ".PAY");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user