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