This commit is contained in:
2026-07-16 18:30:36 +02:00
parent 598b504873
commit e067412db9
4 changed files with 217 additions and 18 deletions

View File

@@ -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;
}
}

View File

@@ -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(

View File

@@ -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();

View File

@@ -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()