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 0bc1cee..4d5c10e 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 @@ -88,10 +88,11 @@ public class TransitionLinkerEnricher implements AnalysisEnricher { continue; } - 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. + // Only unbound *event* path templates (e.g. /{event}) stay unresolved-external. + // Resource placeholders like /{id}/pay must still link when the event is known. + if (tp.isExternal() + && isUnresolvedGenericRestEndpoint(chain.getEntryPoint()) + && !hasConcreteEventEvidence(tp)) { updatedChains.add(chain.toBuilder() .triggerPoint(tp) .matchedTransitions(null) @@ -100,8 +101,9 @@ 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())) { + // Clear spurious external flags (unrelated PathVariable/RequestParam) once we can link. + if (tp.isExternal() + && (!isUnresolvedGenericRestEndpoint(chain.getEntryPoint()) || hasConcreteEventEvidence(tp))) { tp = tp.toBuilder().external(false).build(); } @@ -341,20 +343,77 @@ public class TransitionLinkerEnricher implements AnalysisEnricher { return event.fullIdentifier() != null ? event.fullIdentifier() : event.rawName(); } + /** + * True when the entry path still has an unbound event-carrying placeholder + * ({@code {event}}, {@code {commandKey}}, …). Resource ids like {@code {id}} do not count. + */ private static boolean isUnresolvedGenericRestEndpoint(EntryPoint entryPoint) { if (entryPoint == null) { return false; } - if (entryPoint.getName() != null && entryPoint.getName().contains("{")) { + if (hasUnboundEventPlaceholder(entryPoint.getName())) { return true; } if (entryPoint.getMetadata() != null) { Object path = entryPoint.getMetadata().get("path"); - if (path instanceof String pathValue && pathValue.contains("{")) { + if (path instanceof String pathValue && hasUnboundEventPlaceholder(pathValue)) { return true; } } return false; } + private static boolean hasUnboundEventPlaceholder(String pathOrName) { + if (pathOrName == null || pathOrName.isBlank() || !pathOrName.contains("{")) { + return false; + } + int start = 0; + while (true) { + int open = pathOrName.indexOf('{', start); + if (open < 0) { + return false; + } + int close = pathOrName.indexOf('}', open + 1); + if (close < 0) { + return false; + } + String placeholder = pathOrName.substring(open + 1, close).trim(); + if (isEventLikePlaceholder(placeholder)) { + return true; + } + start = close + 1; + } + } + + private static boolean isEventLikePlaceholder(String placeholder) { + if (placeholder == null || placeholder.isBlank()) { + return false; + } + String lower = placeholder.toLowerCase(); + // Strip matrix/regex extras: event:.+ or event:.* + int colon = lower.indexOf(':'); + if (colon > 0) { + lower = lower.substring(0, colon); + } + return lower.equals("event") + || lower.equals("e") + || lower.endsWith("event") + || lower.equals("command") + || lower.equals("commandkey") + || lower.equals("transition") + || lower.equals("action") + || lower.equals("machinetype"); + } + + private static boolean hasConcreteEventEvidence(TriggerPoint trigger) { + if (trigger == null) { + return false; + } + // Only a resolved enum constant on the trigger event itself bypasses unbound {event} + // templates. Polymorphic widens / single-transition inference must not count — otherwise + // every generic dispatcher template becomes RESOLVED against a one-transition machine. + return MachineEnumCanonicalizer.classifyTriggerEvent(trigger.getEvent()) + == MachineEnumCanonicalizer.TriggerEventKind.CANONICAL_ENUM; + } + } \ No newline at end of file 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 0dc49e9..ec7814a 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 @@ -68,7 +68,7 @@ public final class ExternalTriggerPolicy { } } } - if (entryPoint.getName() != null && entryPoint.getName().contains("{") + if (entryPoint.getName() != null && entryPointHasUnboundPlaceholders(entryPoint) && MachineEnumCanonicalizer.isDynamicTriggerExpression(trigger.getEvent())) { if (paramName != null) { RestParamBinding pathBinding = findRestParameterBinding(entryPoint, paramName, context); @@ -107,18 +107,48 @@ public final class ExternalTriggerPolicy { if (entryPoint == null) { return false; } - if (entryPoint.getName() != null && entryPoint.getName().contains("{")) { + if (hasUnboundEventPlaceholder(entryPoint.getName())) { return true; } if (entryPoint.getMetadata() != null) { String path = entryPoint.getMetadata().get("path"); - if (path != null && path.contains("{")) { + if (hasUnboundEventPlaceholder(path)) { return true; } } return false; } + private static boolean hasUnboundEventPlaceholder(String pathOrName) { + if (pathOrName == null || pathOrName.isBlank() || !pathOrName.contains("{")) { + return false; + } + int start = 0; + while (true) { + int open = pathOrName.indexOf('{', start); + if (open < 0) { + return false; + } + int close = pathOrName.indexOf('}', open + 1); + if (close < 0) { + return false; + } + String placeholder = pathOrName.substring(open + 1, close).trim(); + if (isEventLikeParameterName(placeholderName(placeholder))) { + return true; + } + start = close + 1; + } + } + + private static String placeholderName(String placeholder) { + if (placeholder == null) { + return null; + } + int colon = placeholder.indexOf(':'); + return colon > 0 ? placeholder.substring(0, colon).trim() : placeholder; + } + private static String resolveRestEventParameterName(EntryPoint entryPoint, String resolvedEventParamName) { if (resolvedEventParamName != null && !resolvedEventParamName.isBlank()) { // Only accept identifiers that look like method parameter names, not event FQNs/expressions. @@ -129,8 +159,8 @@ public final class ExternalTriggerPolicy { if (entryPoint.getParameters() == null) { return null; } - // Prefer PathVariable params that look like event carriers over unrelated RequestParams (userId). - String pathVariableFallback = null; + // Prefer PathVariable/RequestParam params that look like event carriers. + // Do NOT fall back to unrelated resource ids ({id}, orderId, userId). for (EntryPoint.Parameter parameter : entryPoint.getParameters()) { if (parameter.getAnnotations() == null) { continue; @@ -143,11 +173,8 @@ public final class ExternalTriggerPolicy { if (isEventLikeParameterName(parameter.getName())) { return parameter.getName(); } - if (pathVariable && pathVariableFallback == null) { - pathVariableFallback = parameter.getName(); - } } - return pathVariableFallback; + return null; } private static boolean looksLikeParameterName(String value) { @@ -171,8 +198,10 @@ public final class ExternalTriggerPolicy { || lower.equals("e") || lower.endsWith("event") || lower.equals("command") + || lower.equals("commandkey") || lower.equals("transition") - || lower.equals("action"); + || lower.equals("action") + || lower.equals("machinetype"); } private static RestParamBinding findRestParameterBinding( 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 9223e77..775ed14 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 @@ -153,6 +153,88 @@ class TransitionLinkerEnricherTest { assertThat(updated.getTriggerPoint().isExternal()).isFalse(); } + @Test + void shouldLinkDedicatedEventEndpointEvenWithResourceIdPlaceholder() { + Transition assign = new Transition(); + assign.setSourceStates(List.of(State.of("OPEN", "com.example.TicketState.OPEN"))); + assign.setTargetStates(List.of(State.of("ASSIGNED", "com.example.TicketState.ASSIGNED"))); + assign.setEvent(Event.of("ASSIGN", "com.example.TicketEvent.ASSIGN")); + + // /{id}/assign has a path placeholder, but it is a resource id — not an unbound event. + CallChain chain = CallChain.builder() + .entryPoint(EntryPoint.builder() + .type(EntryPoint.Type.REST) + .name("POST /api/ticket/{id}/assign") + .className("com.example.WebController") + .methodName("assignTicket") + .parameters(List.of( + EntryPoint.Parameter.builder() + .name("id") + .type("String") + .annotations(List.of("PathVariable")) + .build(), + EntryPoint.Parameter.builder() + .name("userId") + .type("String") + .annotations(List.of("RequestParam")) + .build())) + .build()) + .triggerPoint(TriggerPoint.builder() + .event("com.example.TicketEvent.ASSIGN") + .polymorphicEvents(List.of("com.example.TicketEvent.ASSIGN")) + .external(true) + .build()) + .build(); + + AnalysisResult result = AnalysisResult.builder() + .name("TicketStateMachineConfig") + .eventTypeFqn("com.example.TicketEvent") + .transitions(List.of(assign)) + .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 shouldStillFailClosedOnUnboundEventPathTemplate() { + Transition payT = new Transition(); + payT.setSourceStates(List.of(State.of("NEW", "OrderState.NEW"))); + payT.setTargetStates(List.of(State.of("PAID", "OrderState.PAID"))); + payT.setEvent(Event.of("OrderEvent.PAY", "com.example.OrderEvent.PAY")); + + CallChain chain = CallChain.builder() + .entryPoint(EntryPoint.builder() + .type(EntryPoint.Type.REST) + .name("POST /api/machine/{machineType}/transition/{event}") + .className("com.example.Api") + .methodName("transition") + .build()) + .triggerPoint(TriggerPoint.builder() + .event("OrderEvent.valueOf(eventStr)") + .external(true) + .ambiguous(true) + .build()) + .build(); + + AnalysisResult result = AnalysisResult.builder() + .name("OrderStateMachineConfig") + .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.UNRESOLVED_EXTERNAL); + assertThat(updated.getMatchedTransitions()).isNullOrEmpty(); + } + @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 5916e20..0ddc6a8 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 @@ -155,6 +155,35 @@ class ExternalTriggerPolicyTest { entryPoint, trigger, "com.example.Api.payOrder", null, null)).isFalse(); } + @Test + void resourceIdPathVariableShouldNotMakeDedicatedEndpointExternal() { + EntryPoint entryPoint = EntryPoint.builder() + .type(EntryPoint.Type.REST) + .name("POST /api/ticket/{id}/assign") + .className("com.example.WebController") + .methodName("assignTicket") + .parameters(List.of( + EntryPoint.Parameter.builder() + .name("id") + .type("String") + .annotations(List.of("PathVariable")) + .build(), + EntryPoint.Parameter.builder() + .name("userId") + .type("String") + .annotations(List.of("RequestParam")) + .build())) + .build(); + TriggerPoint trigger = TriggerPoint.builder() + .event("com.example.TicketEvent.ASSIGN") + .polymorphicEvents(List.of("com.example.TicketEvent.ASSIGN")) + .external(true) + .build(); + + assertThat(ExternalTriggerPolicy.isExternalFromSource( + entryPoint, trigger, "com.example.WebController.assignTicket", null, null)).isFalse(); + } + @Test void concreteEnumLiteralFromDedicatedEndpointShouldBeInternal() { EntryPoint entryPoint = EntryPoint.builder()