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",