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 new file mode 100644 index 0000000..010058f --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/CallChainPolyNarrower.java @@ -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. + * + *

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 machineTransitions, + CodebaseContext context) { + if (trigger == null || machineTypes == null || machineTypes.eventTypeFqn() == null) { + return trigger; + } + List poly = trigger.getPolymorphicEvents(); + if (poly == null || poly.size() <= 1) { + return trigger; + } + + String eventTypeFqn = machineTypes.eventTypeFqn(); + Set 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 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 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 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 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 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 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 methodChain, + Set 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 configuredConstants, + CodebaseContext context, + HintVotes votes) { + if (context == null) { + return; + } + List 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 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 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 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 configuredConstants, + HintVotes votes, + int weight) { + if (identifier == null || identifier.isBlank()) { + return; + } + String upper = identifier.toUpperCase(Locale.ROOT); + List 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 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 filterPolyToConstant( + List poly, + String constant, + String eventTypeFqn, + CodebaseContext context) { + List narrowed = new ArrayList<>(); + for (String candidate : poly) { + if (candidate == null || candidate.startsWith(" splitCamelCase(String identifier) { + if (identifier == null || identifier.isBlank()) { + return List.of(); + } + List 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 configuredEventConstants( + List transitions, + String eventTypeFqn, + CodebaseContext context) { + Set 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 scores = new HashMap<>(); + private final Map 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 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 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; + } + } +} 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 f6590a8..1b7290e 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 @@ -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) { 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 new file mode 100644 index 0000000..726168c --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/CallChainPolyNarrowerTest.java @@ -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; + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/DispatcherPolyCeilingPipelineTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/DispatcherPolyCeilingPipelineTest.java index 31cc9f8..2e8f13d 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/DispatcherPolyCeilingPipelineTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/DispatcherPolyCeilingPipelineTest.java @@ -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