diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/CallChainLinkPolicy.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/CallChainLinkPolicy.java index cb7f1c1..cd14ba0 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/CallChainLinkPolicy.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/CallChainLinkPolicy.java @@ -260,16 +260,20 @@ public final class CallChainLinkPolicy { boolean ambiguousSource, String machineEventTypeFqn, CodebaseContext context) { + // Prefer concrete matches over an external flag — dedicated endpoints can be + // mis-tagged external when unrelated RequestParams appear in path bindings. + if (matched != null && !matched.isEmpty()) { + if (shouldFailClosedOnAmbiguousCallGraphWiden(trigger, machineEventTypeFqn, context)) { + return LinkResolution.AMBIGUOUS_WIDEN; + } + return LinkResolution.RESOLVED; + } if (trigger != null && trigger.isExternal()) { return LinkResolution.UNRESOLVED_EXTERNAL; } if (shouldFailClosedOnAmbiguousCallGraphWiden(trigger, machineEventTypeFqn, context)) { return LinkResolution.AMBIGUOUS_WIDEN; } - // Prefer keeping useful matches over soft source ambiguity (e.g. multi-place same event). - if (matched != null && !matched.isEmpty()) { - return LinkResolution.RESOLVED; - } if (ambiguousSource) { return LinkResolution.AMBIGUOUS_WIDEN; } @@ -282,8 +286,9 @@ public final class CallChainLinkPolicy { } EntryPoint entryPoint = chain.getEntryPoint(); String entryMethod = entryPoint.getClassName() + "." + entryPoint.getMethodName(); + // Pass null for event param name — trigger.getEvent() is an expression/FQN, not a REST param. boolean external = ExternalTriggerPolicy.isExternalFromSource( - entryPoint, trigger, entryMethod, trigger.getEvent(), context); + entryPoint, trigger, entryMethod, null, context); if (external == trigger.isExternal()) { return trigger; } 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 bd5ba8b..0bc1cee 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 @@ -89,6 +89,9 @@ public class TransitionLinkerEnricher implements AnalysisEnricher { } if (tp.isExternal() && isUnresolvedGenericRestEndpoint(chain.getEntryPoint())) { + // Unbound {event}/{machineType} templates stay external with no matches. + // Dedicated/expanded endpoints (no placeholders) continue to linking even if + // the external flag was set from an unrelated RequestParam. updatedChains.add(chain.toBuilder() .triggerPoint(tp) .matchedTransitions(null) @@ -97,6 +100,11 @@ public class TransitionLinkerEnricher implements AnalysisEnricher { continue; } + // Dedicated REST endpoints must not stay stuck as external once placeholders are gone. + if (tp.isExternal() && !isUnresolvedGenericRestEndpoint(chain.getEntryPoint())) { + tp = tp.toBuilder().external(false).build(); + } + String triggerSource = tp.getSourceState() != null ? simplifySourceState(tp.getSourceState()) : null; if (CallChainLinkPolicy.shouldFailClosedOnAmbiguousCallGraphWiden( tp, diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AbstractCallGraphEngine.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AbstractCallGraphEngine.java index 77f57a7..72ae9b7 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AbstractCallGraphEngine.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AbstractCallGraphEngine.java @@ -3109,16 +3109,9 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { List path, Map> callGraph, Map initialBindings) { - if (isExternalParameter(entryMethod, finalParamName)) { - return true; - } - Map bindings = resolvePathBindings(path, callGraph, initialBindings); - for (String boundName : bindings.keySet()) { - if (isExternalParameter(entryMethod, boundName)) { - return true; - } - } - return false; + // Only the resolved event parameter makes a trigger "external". Unrelated REST params + // (e.g. @RequestParam userId on a dedicated /order/pay endpoint) must not flip the flag. + return finalParamName != null && isExternalParameter(entryMethod, finalParamName); } private List resolveKeyedMapLookup( diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/ExternalTriggerPolicy.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/ExternalTriggerPolicy.java index 6f3c7e6..0dc49e9 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/ExternalTriggerPolicy.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/ExternalTriggerPolicy.java @@ -121,22 +121,58 @@ public final class ExternalTriggerPolicy { private static String resolveRestEventParameterName(EntryPoint entryPoint, String resolvedEventParamName) { if (resolvedEventParamName != null && !resolvedEventParamName.isBlank()) { - return resolvedEventParamName; + // Only accept identifiers that look like method parameter names, not event FQNs/expressions. + if (looksLikeParameterName(resolvedEventParamName)) { + return resolvedEventParamName; + } } if (entryPoint.getParameters() == null) { return null; } + // Prefer PathVariable params that look like event carriers over unrelated RequestParams (userId). + String pathVariableFallback = null; for (EntryPoint.Parameter parameter : entryPoint.getParameters()) { if (parameter.getAnnotations() == null) { continue; } - for (String annotation : parameter.getAnnotations()) { - if ("PathVariable".equals(annotation) || "RequestParam".equals(annotation)) { - return parameter.getName(); - } + boolean pathVariable = parameter.getAnnotations().stream().anyMatch("PathVariable"::equals); + boolean requestParam = parameter.getAnnotations().stream().anyMatch("RequestParam"::equals); + if (!pathVariable && !requestParam) { + continue; + } + if (isEventLikeParameterName(parameter.getName())) { + return parameter.getName(); + } + if (pathVariable && pathVariableFallback == null) { + pathVariableFallback = parameter.getName(); } } - return null; + return pathVariableFallback; + } + + private static boolean looksLikeParameterName(String value) { + if (value == null || value.isBlank()) { + return false; + } + // Reject FQNs, method calls, and dotted expressions — those are event values, not param names. + if (value.contains(".") || value.contains("(") || value.contains(")") || value.contains(" ")) { + return false; + } + return Character.isJavaIdentifierStart(value.charAt(0)) + && value.chars().allMatch(Character::isJavaIdentifierPart); + } + + private static boolean isEventLikeParameterName(String name) { + if (name == null || name.isBlank()) { + return false; + } + String lower = name.toLowerCase(); + return lower.equals("event") + || lower.equals("e") + || lower.endsWith("event") + || lower.equals("command") + || lower.equals("transition") + || lower.equals("action"); } private static RestParamBinding findRestParameterBinding( diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/CallChainLinkPolicyTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/CallChainLinkPolicyTest.java index 4ca9af4..d07c427 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/CallChainLinkPolicyTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/CallChainLinkPolicyTest.java @@ -34,6 +34,24 @@ class CallChainLinkPolicyTest { .isEqualTo(LinkResolution.UNRESOLVED_EXTERNAL); } + @Test + void shouldPreferMatchesOverExternalFlag() { + TriggerPoint trigger = TriggerPoint.builder() + .external(true) + .event("OrderEvent.PAY") + .polymorphicEvents(List.of("OrderEvent.PAY")) + .build(); + + var matched = List.of( + click.kamil.springstatemachineexporter.analysis.model.MatchedTransition.builder() + .event("com.example.order.OrderEvent.PAY") + .sourceState("com.example.order.OrderState.NEW") + .build()); + + assertThat(CallChainLinkPolicy.resolveLinkResolution(trigger, matched, false)) + .isEqualTo(LinkResolution.RESOLVED); + } + @Test void shouldFailClosedOnValueOfAmbiguousWiden() { TriggerPoint trigger = TriggerPoint.builder() diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/TransitionLinkerEnricherTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/TransitionLinkerEnricherTest.java index ac1362e..9223e77 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/TransitionLinkerEnricherTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/TransitionLinkerEnricherTest.java @@ -112,6 +112,47 @@ class TransitionLinkerEnricherTest { assertThat(updated.getMatchedTransitions()).isNullOrEmpty(); } + @Test + void shouldResolveDedicatedEndpointEvenWhenMisTaggedExternal() { + Transition payT = new Transition(); + payT.setSourceStates(List.of(State.of("NEW", "com.example.OrderState.NEW"))); + payT.setTargetStates(List.of(State.of("PAID", "com.example.OrderState.PAID"))); + payT.setEvent(Event.of("PAY", "com.example.OrderEvent.PAY")); + + CallChain chain = CallChain.builder() + .entryPoint(EntryPoint.builder() + .type(EntryPoint.Type.REST) + .name("POST /api/machine/order/pay") + .className("com.example.Api") + .methodName("payOrder") + .parameters(List.of(EntryPoint.Parameter.builder() + .name("userId") + .type("String") + .annotations(List.of("RequestParam")) + .build())) + .build()) + .triggerPoint(TriggerPoint.builder() + .event("com.example.OrderEvent.PAY") + .polymorphicEvents(List.of("com.example.OrderEvent.PAY")) + .external(true) + .build()) + .build(); + + AnalysisResult result = AnalysisResult.builder() + .name("OrderStateMachineConfig") + .eventTypeFqn("com.example.OrderEvent") + .transitions(List.of(payT)) + .metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build()) + .build(); + + enricher.enrich(result, null, null); + + CallChain updated = result.getMetadata().getCallChains().get(0); + assertThat(updated.getLinkResolution()).isEqualTo(LinkResolution.RESOLVED); + assertThat(updated.getMatchedTransitions()).hasSize(1); + assertThat(updated.getTriggerPoint().isExternal()).isFalse(); + } + @Test void shouldLinkWhenSymbolicPolymorphicEventsExpandToMachineEnumConstants() { Transition payT = new Transition(); diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/ExternalTriggerPolicyTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/ExternalTriggerPolicyTest.java index 6f83d34..5916e20 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/ExternalTriggerPolicyTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/ExternalTriggerPolicyTest.java @@ -132,6 +132,29 @@ class ExternalTriggerPolicyTest { entryPoint, trigger, "com.example.Api.pay", "event", context)).isFalse(); } + @Test + void dedicatedEndpointWithUnrelatedRequestParamShouldBeInternal() { + EntryPoint entryPoint = EntryPoint.builder() + .type(EntryPoint.Type.REST) + .name("POST /api/machine/order/pay") + .className("com.example.Api") + .methodName("payOrder") + .parameters(List.of(EntryPoint.Parameter.builder() + .name("userId") + .type("String") + .annotations(List.of("RequestParam")) + .build())) + .build(); + TriggerPoint trigger = TriggerPoint.builder() + .event("com.example.OrderEvent.PAY") + .polymorphicEvents(List.of("com.example.OrderEvent.PAY")) + .external(true) // call-graph may have mis-tagged via userId binding + .build(); + + assertThat(ExternalTriggerPolicy.isExternalFromSource( + entryPoint, trigger, "com.example.Api.payOrder", null, null)).isFalse(); + } + @Test void concreteEnumLiteralFromDedicatedEndpointShouldBeInternal() { EntryPoint entryPoint = EntryPoint.builder()