diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/CallChainPolyNarrower.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/CallChainPolyNarrower.java
index 97b9e76..997021a 100644
--- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/CallChainPolyNarrower.java
+++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/CallChainPolyNarrower.java
@@ -2,7 +2,6 @@ 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.ConstantResolver;
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
@@ -29,10 +28,12 @@ import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
- * Narrows wide call-graph polymorphic widens to endpoint-specific enum constants.
+ * Narrows wide call-graph polymorphic widens using dataflow evidence only.
*
- *
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.
+ *
Evidence sources: static trigger events, path-binding map values, branch constraints,
+ * sendEvent enum literals along the method chain, and AST path-value estimation.
+ * REST URL path segments and method-name tokens are intentionally not used — those are not
+ * dataflow and falsely link endpoints to transitions.
*/
public final class CallChainPolyNarrower {
@@ -122,10 +123,9 @@ public final class CallChainPolyNarrower {
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);
+ collectFromPathBindings(chain.getPathBindings(), configuredConstants, votes);
collectFromSendEventLiterals(chain.getMethodChain(), configuredConstants, context, votes);
collectFromPathEstimator(chain, configuredConstants, context, votes);
}
@@ -145,41 +145,33 @@ public final class CallChainPolyNarrower {
addEnumReference(event, configuredConstants, votes, STRONG);
}
- private static void collectFromEntryPoint(
- EntryPoint entryPoint,
+ /**
+ * Path bindings come from {@code PathBindingEvaluator} / call-graph dataflow, not from URL text.
+ */
+ private static void collectFromPathBindings(
+ Map pathBindings,
Set configuredConstants,
HintVotes votes) {
- if (entryPoint == null) {
+ if (pathBindings == null || pathBindings.isEmpty()) {
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("{")) {
+ for (Map.Entry entry : pathBindings.entrySet()) {
+ String key = entry.getKey();
+ String value = entry.getValue();
+ if (value == null || value.isBlank()) {
continue;
}
- addPathToken(segment, configuredConstants, votes, STRONG);
+ boolean eventLikeKey = key != null && (
+ key.equalsIgnoreCase("event")
+ || key.equalsIgnoreCase("eventString")
+ || key.equalsIgnoreCase("command")
+ || key.equalsIgnoreCase("commandKey")
+ || key.equalsIgnoreCase("action")
+ || key.equalsIgnoreCase("actionKey")
+ || key.toLowerCase(Locale.ROOT).endsWith("event"));
+ int weight = eventLikeKey ? STRONG : MEDIUM;
+ addEnumReference(value, configuredConstants, votes, weight);
+ addPathToken(value, configuredConstants, votes, weight);
}
}
@@ -222,30 +214,6 @@ public final class CallChainPolyNarrower {
}
}
- 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);
- }
- String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
- int nestedDot = className.lastIndexOf('.');
- if (nestedDot >= 0) {
- addUniqueIdentifierMatch(className.substring(nestedDot + 1), configuredConstants, votes, WEAK);
- }
- }
- }
-
private static void collectFromSendEventLiterals(
List methodChain,
Set configuredConstants,
@@ -280,18 +248,28 @@ public final class CallChainPolyNarrower {
methodDeclaration.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(MethodInvocation node) {
- if (!FIRE_METHOD_NAMES.contains(node.getName().getIdentifier())) {
- return super.visit(node);
- }
+ boolean fireSite = FIRE_METHOD_NAMES.contains(node.getName().getIdentifier());
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 {
+ // Enum constant passed into the next hop (dataflow at the call site).
+ addEnumReference(
+ qualifiedName.getFullyQualifiedName(),
+ configuredConstants,
+ votes,
+ STRONG);
+ } else if (fireSite && expression instanceof SimpleName simpleName) {
+ // Only accept UPPERCASE identifiers that are configured constants —
+ // plain parameter names like "event" must not vote.
+ String identifier = simpleName.getIdentifier();
+ if (identifier != null
+ && identifier.equals(identifier.toUpperCase(Locale.ROOT))
+ && configuredConstants.contains(identifier)) {
+ addEnumReference(identifier, configuredConstants, votes, STRONG);
+ }
+ } else if (fireSite) {
String resolved = resolver.resolve(expression, context);
if (resolved != null) {
addEnumReference(resolved, configuredConstants, votes, STRONG);
diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizer.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizer.java
index 49a9ef8..fcfcf23 100644
--- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizer.java
+++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizer.java
@@ -1136,9 +1136,7 @@ public final class MachineEnumCanonicalizer {
List boundEventLiterals = constraint != null && !constraint.isBlank()
? extractBoundEventLiteralsFromConstraint(constraint)
: List.of();
- if (boundEventLiterals.size() != 1) {
- boundEventLiterals = extractEventLiteralsFromEntryPointPath(entryPoint);
- }
+ // Dataflow-only: never invent event constants from REST URL path segments.
if (boundEventLiterals.size() != 1) {
return trigger;
}
@@ -1146,19 +1144,13 @@ public final class MachineEnumCanonicalizer {
if (eventTypeFqn == null || eventTypeFqn.isBlank()) {
return trigger;
}
- String literal = boundEventLiterals.get(0).toUpperCase();
- if (entryPoint != null && entryPoint.getName() != null
- && !entryPoint.getName().toUpperCase().contains("/" + literal + "/")
- && !entryPoint.getName().toUpperCase().endsWith("/" + literal)) {
- return trigger;
- }
- String machineTypeLiteral = constraint != null
- ? extractBoundParamLiteralFromConstraint(constraint, "machineType")
- : null;
- if (machineTypeLiteral != null && entryPoint != null && entryPoint.getName() != null
- && !entryPoint.getName().toUpperCase().contains("/" + machineTypeLiteral.toUpperCase() + "/")) {
- return trigger;
+ String literal = boundEventLiterals.get(0);
+ // Command keys like "order.pay" are not enum constants — take the trailing segment.
+ int lastDot = literal.lastIndexOf('.');
+ if (lastDot >= 0 && lastDot + 1 < literal.length()) {
+ literal = literal.substring(lastDot + 1);
}
+ literal = literal.toUpperCase();
String constantFqn = eventTypeFqn + "." + literal;
if (context != null) {
List machineEnumValues = context.getEnumValues(eventTypeFqn);
@@ -1173,49 +1165,21 @@ public final class MachineEnumCanonicalizer {
.build();
}
- private static String extractBoundParamLiteralFromConstraint(String constraint, String paramName) {
- java.util.regex.Matcher matcher = java.util.regex.Pattern
- .compile("\"([^\"]+)\"\\.equalsIgnoreCase\\(" + paramName + "\\)")
- .matcher(constraint);
- if (matcher.find()) {
- return matcher.group(1);
- }
- return null;
- }
-
private static List extractBoundEventLiteralsFromConstraint(String constraint) {
List literals = new ArrayList<>();
java.util.regex.Matcher matcher = java.util.regex.Pattern
.compile("\"([^\"]+)\"\\.equalsIgnoreCase\\((\\w+)\\)")
.matcher(constraint);
while (matcher.find()) {
- if ("event".equals(matcher.group(2))) {
+ String param = matcher.group(2);
+ if ("event".equals(param)
+ || "eventString".equals(param)
+ || "actionKey".equals(param)
+ || "action".equals(param)
+ || "commandKey".equals(param)) {
literals.add(matcher.group(1));
}
}
return literals;
}
-
- private static List extractEventLiteralsFromEntryPointPath(EntryPoint entryPoint) {
- if (entryPoint == null || entryPoint.getName() == null || entryPoint.getName().isBlank()) {
- return List.of();
- }
- String rawPath = entryPoint.getName();
- int space = rawPath.indexOf(' ');
- String path = space >= 0 ? rawPath.substring(space + 1).trim() : rawPath.trim();
- if (path.isBlank()) {
- return List.of();
- }
- String[] segments = path.split("/");
- for (int i = segments.length - 1; i >= 0; i--) {
- String segment = segments[i];
- if (segment.isBlank() || segment.contains("{")) {
- continue;
- }
- if (segment.matches("[A-Z_][A-Z0-9_]*")) {
- return List.of(segment);
- }
- }
- return List.of();
- }
}
diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/CallChainPolyNarrowerTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/CallChainPolyNarrowerTest.java
index bffee9d..8664610 100644
--- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/CallChainPolyNarrowerTest.java
+++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/CallChainPolyNarrowerTest.java
@@ -24,10 +24,11 @@ class CallChainPolyNarrowerTest {
private final TransitionLinkerEnricher enricher = new TransitionLinkerEnricher();
@Test
- void shouldNarrowWidePolyUsingExpandedRestPathSegment() {
+ void shouldNotNarrowUsingRestPathSegmentAlone() {
Transition pay = transition("NEW", "PAID", "com.example.OrderEvent.PAY");
Transition ship = transition("PAID", "SHIPPED", "com.example.OrderEvent.SHIP");
+ // Wide poly + URL containing /PAY must NOT invent a narrow — that is not dataflow.
CallChain chain = CallChain.builder()
.entryPoint(EntryPoint.builder()
.name("POST /api/machine/ORDER/transition/PAY")
@@ -58,6 +59,47 @@ class CallChainPolyNarrowerTest {
enricher.enrich(result, null, null);
+ CallChain linked = result.getMetadata().getCallChains().get(0);
+ assertThat(linked.getMatchedTransitions()).isNullOrEmpty();
+ assertThat(linked.getLinkResolution()).isEqualTo(LinkResolution.AMBIGUOUS_WIDEN);
+ }
+
+ @Test
+ void shouldNarrowUsingConstraintBindingEvidence() {
+ 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)")
+ .constraint("\"PAY\".equalsIgnoreCase(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, null, null);
+
CallChain linked = result.getMetadata().getCallChains().get(0);
assertThat(linked.getLinkResolution()).isEqualTo(LinkResolution.RESOLVED);
assertThat(linked.getMatchedTransitions()).hasSize(1);
@@ -68,7 +110,7 @@ class CallChainPolyNarrowerTest {
}
@Test
- void shouldNarrowWidePolyUsingRichDispatcherMethodName() {
+ void shouldNotNarrowUsingMethodNameTokensAlone() {
Transition pay = transition("NEW", "PAID", "com.example.OrderEvent.PAY");
Transition ship = transition("PAID", "SHIPPED", "com.example.OrderEvent.SHIP");
@@ -103,6 +145,43 @@ class CallChainPolyNarrowerTest {
enricher.enrich(result, null, null);
+ CallChain linked = result.getMetadata().getCallChains().get(0);
+ assertThat(linked.getMatchedTransitions()).isNullOrEmpty();
+ assertThat(linked.getLinkResolution()).isEqualTo(LinkResolution.AMBIGUOUS_WIDEN);
+ }
+
+ @Test
+ void shouldNarrowUsingPathBindingsFromDataflow() {
+ 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())
+ .pathBindings(java.util.Map.of("event", "com.example.OrderEvent.PAY"))
+ .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);
@@ -293,15 +372,18 @@ class CallChainPolyNarrowerTest {
Transition pay = transition("NEW", "PAID", "com.example.OrderEvent.PAY");
Transition ship = transition("PAID", "SHIPPED", "com.example.OrderEvent.SHIP");
+ // Without eventTypeFqn, path text must not invent PAY — require dataflow bindings/constraints.
CallChain chain = CallChain.builder()
.entryPoint(EntryPoint.builder()
.name("POST /api/machine/ORDER/transition/PAY")
.className("com.example.MachineController")
.methodName("transition")
.build())
+ .pathBindings(java.util.Map.of("event", "PAY"))
.triggerPoint(TriggerPoint.builder()
.ambiguous(true)
.event("OrderEvent.valueOf(eventStr)")
+ .eventTypeFqn("com.example.OrderEvent")
.polymorphicEvents(List.of(
"com.example.OrderEvent.PAY",
"com.example.OrderEvent.SHIP",