This commit is contained in:
2026-07-16 18:21:18 +02:00
parent 9250e97055
commit 598b504873
7 changed files with 145 additions and 21 deletions

View File

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

View File

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

View File

@@ -3109,16 +3109,9 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
List<String> path,
Map<String, List<CallEdge>> callGraph,
Map<String, String> initialBindings) {
if (isExternalParameter(entryMethod, finalParamName)) {
return true;
}
Map<String, String> 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<String> resolveKeyedMapLookup(

View File

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

View File

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

View File

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

View File

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