A
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user