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:
2026-07-14 20:08:09 +02:00
parent 1957266a98
commit 758d433726
4 changed files with 625 additions and 5 deletions

View File

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

View File

@@ -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) {

View File

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

View File

@@ -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