D
This commit is contained in:
@@ -4,6 +4,7 @@ import click.kamil.springstatemachineexporter.analysis.enricher.routing.BeanReso
|
||||
import click.kamil.springstatemachineexporter.analysis.enricher.routing.HeuristicBeanResolutionEngine;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.EntryPointKeys;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
|
||||
import click.kamil.springstatemachineexporter.analysis.service.CallGraphEngine;
|
||||
@@ -46,14 +47,14 @@ public final class EntryPointScopeResolver {
|
||||
for (CallChain chain : chains) {
|
||||
EntryPoint entryPoint = chain.getEntryPoint();
|
||||
if (entryPoint != null) {
|
||||
scoped.putIfAbsent(entryPointKey(entryPoint), entryPoint);
|
||||
scoped.putIfAbsent(EntryPointKeys.of(entryPoint), entryPoint);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (allEntryPoints != null) {
|
||||
for (EntryPoint entryPoint : allEntryPoints) {
|
||||
if (hasPathVariablePlaceholder(entryPoint)) {
|
||||
scoped.putIfAbsent(entryPointKey(entryPoint), entryPoint);
|
||||
scoped.putIfAbsent(EntryPointKeys.of(entryPoint), entryPoint);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -150,11 +151,4 @@ public final class EntryPointScopeResolver {
|
||||
InjectionPointAnalyzer injectionAnalyzer = new InjectionPointAnalyzer(dependencyResolver);
|
||||
return new JdtCallGraphEngine(context, injectionAnalyzer);
|
||||
}
|
||||
|
||||
private static String entryPointKey(EntryPoint entryPoint) {
|
||||
if (entryPoint.getName() != null && !entryPoint.getName().isBlank()) {
|
||||
return entryPoint.getName();
|
||||
}
|
||||
return entryPoint.getClassName() + "#" + entryPoint.getMethodName();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.enricher;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||
import click.kamil.springstatemachineexporter.analysis.resolver.ConstraintClauses;
|
||||
import click.kamil.springstatemachineexporter.analysis.resolver.DynamicTriggerExpressions;
|
||||
import click.kamil.springstatemachineexporter.analysis.service.EntryPointBindingExpander;
|
||||
import click.kamil.springstatemachineexporter.analysis.service.EventCarrierPolicy;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* When an expandable event-carrier entry (REST {@code {event}} / PathVariable carrier / messaging
|
||||
* payload) still has an unbound {@code Enum.valueOf} multi-constant widen, synthesize one call
|
||||
* chain per concrete polymorphic event so linking can proceed without softening fail-closed for
|
||||
* non-expandable shared dispatchers.
|
||||
*/
|
||||
public final class ExpandableEntryPolySplitter {
|
||||
|
||||
private ExpandableEntryPolySplitter() {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the original chain alone, or N split chains (never empty)
|
||||
*/
|
||||
public static List<CallChain> maybeExpand(
|
||||
CallChain chain,
|
||||
TriggerPoint preparedTrigger,
|
||||
String machineEventTypeFqn,
|
||||
CodebaseContext context) {
|
||||
if (chain == null || preparedTrigger == null) {
|
||||
return chain == null ? List.of() : List.of(chain);
|
||||
}
|
||||
CallChain withPrepared = chain.toBuilder().triggerPoint(preparedTrigger).build();
|
||||
if (!shouldSplit(withPrepared, preparedTrigger, machineEventTypeFqn, context)) {
|
||||
return List.of(withPrepared);
|
||||
}
|
||||
List<String> concrete = CallChainLinkPolicy.concretePolymorphicCandidates(
|
||||
preparedTrigger.getPolymorphicEvents(),
|
||||
eventTypeFqn(preparedTrigger, machineEventTypeFqn),
|
||||
context);
|
||||
if (concrete.size() <= 1) {
|
||||
return List.of(withPrepared);
|
||||
}
|
||||
String carrierParam = resolveCarrierParamName(chain.getEntryPoint());
|
||||
List<CallChain> split = new ArrayList<>(concrete.size());
|
||||
for (String constantFqn : concrete) {
|
||||
String literal = constantLiteral(constantFqn);
|
||||
Map<String, String> bindings = new LinkedHashMap<>();
|
||||
if (chain.getPathBindings() != null) {
|
||||
bindings.putAll(chain.getPathBindings());
|
||||
}
|
||||
if (carrierParam != null && literal != null) {
|
||||
bindings.put(carrierParam, literal);
|
||||
}
|
||||
EntryPoint entryPoint = chain.getEntryPoint();
|
||||
if (entryPoint != null && !bindings.isEmpty()) {
|
||||
entryPoint = EntryPointBindingExpander.withResolvedPath(entryPoint, bindings);
|
||||
}
|
||||
TriggerPoint single = preparedTrigger.toBuilder()
|
||||
.polymorphicEvents(List.of(constantFqn))
|
||||
.ambiguous(false)
|
||||
.external(false)
|
||||
.build();
|
||||
split.add(chain.toBuilder()
|
||||
.entryPoint(entryPoint)
|
||||
.triggerPoint(single)
|
||||
.pathBindings(bindings.isEmpty() ? null : Map.copyOf(bindings))
|
||||
.build());
|
||||
}
|
||||
return split;
|
||||
}
|
||||
|
||||
static boolean shouldSplit(
|
||||
CallChain chain,
|
||||
TriggerPoint trigger,
|
||||
String machineEventTypeFqn,
|
||||
CodebaseContext context) {
|
||||
if (chain == null || trigger == null) {
|
||||
return false;
|
||||
}
|
||||
if (!isExpandableEventCarrierEntry(chain.getEntryPoint())) {
|
||||
return false;
|
||||
}
|
||||
if (hasUnboundMachineDiscriminator(chain.getEntryPoint(), chain.getPathBindings())) {
|
||||
// Dual templates like /{machineType}/…/{event} stay fail-closed until the machine
|
||||
// route is bound; event-only fan-out would over-link across domains.
|
||||
return false;
|
||||
}
|
||||
if (!ConstraintClauses.carrierLiteralsFromPathBindings(chain.getPathBindings()).isEmpty()) {
|
||||
// Already narrowed by expander / dataflow — do not re-split.
|
||||
return false;
|
||||
}
|
||||
if (!ConstraintClauses.boundCarrierLiterals(trigger.getConstraint(), chain.getPathBindings()).isEmpty()
|
||||
&& ConstraintClauses.boundCarrierLiterals(trigger.getConstraint(), chain.getPathBindings()).size()
|
||||
== 1) {
|
||||
return false;
|
||||
}
|
||||
String event = trigger.getEvent();
|
||||
if (event == null || !DynamicTriggerExpressions.isDynamic(event) || !event.contains(".valueOf(")) {
|
||||
return false;
|
||||
}
|
||||
if (!CallChainLinkPolicy.isTrustedEnumPolymorphicWiden(trigger, machineEventTypeFqn, context)) {
|
||||
return false;
|
||||
}
|
||||
List<String> concrete = CallChainLinkPolicy.concretePolymorphicCandidates(
|
||||
trigger.getPolymorphicEvents(),
|
||||
eventTypeFqn(trigger, machineEventTypeFqn),
|
||||
context);
|
||||
return concrete.size() > 1;
|
||||
}
|
||||
|
||||
static boolean isExpandableEventCarrierEntry(EntryPoint entryPoint) {
|
||||
if (entryPoint == null) {
|
||||
return false;
|
||||
}
|
||||
if (hasEventLikePlaceholder(entryPoint.getName())) {
|
||||
return true;
|
||||
}
|
||||
if (entryPoint.getMetadata() != null) {
|
||||
Object path = entryPoint.getMetadata().get("path");
|
||||
if (path instanceof String pathValue && hasEventLikePlaceholder(pathValue)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (entryPoint.getParameters() == null) {
|
||||
return false;
|
||||
}
|
||||
for (EntryPoint.Parameter parameter : entryPoint.getParameters()) {
|
||||
if (EventCarrierPolicy.isCarrierParameter(entryPoint, parameter)) {
|
||||
return true;
|
||||
}
|
||||
if (isPathOrQueryVariable(parameter) && EventCarrierPolicy.isCarrierParamName(parameter.getName())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the entry advertises a machine-route discriminant that is still unbound.
|
||||
*/
|
||||
static boolean hasUnboundMachineDiscriminator(EntryPoint entryPoint, Map<String, String> pathBindings) {
|
||||
if (entryPoint == null) {
|
||||
return false;
|
||||
}
|
||||
for (String name : machineDiscriminatorNames(entryPoint)) {
|
||||
if (!isBound(pathBindings, name)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static List<String> machineDiscriminatorNames(EntryPoint entryPoint) {
|
||||
List<String> names = new ArrayList<>();
|
||||
collectMachineDiscriminatorPlaceholders(entryPoint.getName(), names);
|
||||
if (entryPoint.getMetadata() != null
|
||||
&& entryPoint.getMetadata().get("path") instanceof String path) {
|
||||
collectMachineDiscriminatorPlaceholders(path, names);
|
||||
}
|
||||
if (entryPoint.getParameters() != null) {
|
||||
for (EntryPoint.Parameter parameter : entryPoint.getParameters()) {
|
||||
if (parameter == null || parameter.getName() == null) {
|
||||
continue;
|
||||
}
|
||||
if (EventCarrierPolicy.isMachineDiscriminatorName(parameter.getName())
|
||||
&& (isPathOrQueryVariable(parameter)
|
||||
|| entryPoint.getType() == EntryPoint.Type.REST)) {
|
||||
if (!names.contains(parameter.getName())) {
|
||||
names.add(parameter.getName());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
private static void collectMachineDiscriminatorPlaceholders(String pathOrName, List<String> out) {
|
||||
if (pathOrName == null || pathOrName.isBlank() || !pathOrName.contains("{")) {
|
||||
return;
|
||||
}
|
||||
int start = 0;
|
||||
while (true) {
|
||||
int open = pathOrName.indexOf('{', start);
|
||||
if (open < 0) {
|
||||
return;
|
||||
}
|
||||
int close = pathOrName.indexOf('}', open + 1);
|
||||
if (close < 0) {
|
||||
return;
|
||||
}
|
||||
String placeholder = pathOrName.substring(open + 1, close).trim();
|
||||
int colon = placeholder.indexOf(':');
|
||||
if (colon > 0) {
|
||||
placeholder = placeholder.substring(0, colon);
|
||||
}
|
||||
if (EventCarrierPolicy.isMachineDiscriminatorName(placeholder) && !out.contains(placeholder)) {
|
||||
out.add(placeholder);
|
||||
}
|
||||
start = close + 1;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isBound(Map<String, String> pathBindings, String name) {
|
||||
if (pathBindings == null || name == null || name.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
String value = pathBindings.get(name);
|
||||
if (value != null && !value.isBlank()) {
|
||||
return true;
|
||||
}
|
||||
// Case-insensitive key fallback (expander sometimes normalizes).
|
||||
for (Map.Entry<String, String> entry : pathBindings.entrySet()) {
|
||||
if (entry.getKey() != null
|
||||
&& entry.getKey().equalsIgnoreCase(name)
|
||||
&& entry.getValue() != null
|
||||
&& !entry.getValue().isBlank()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static String resolveCarrierParamName(EntryPoint entryPoint) {
|
||||
if (entryPoint == null) {
|
||||
return null;
|
||||
}
|
||||
if (entryPoint.getParameters() != null) {
|
||||
for (EntryPoint.Parameter parameter : entryPoint.getParameters()) {
|
||||
if (EventCarrierPolicy.isCarrierParameter(entryPoint, parameter)
|
||||
|| (isPathOrQueryVariable(parameter)
|
||||
&& EventCarrierPolicy.isCarrierParamName(parameter.getName()))) {
|
||||
return parameter.getName();
|
||||
}
|
||||
}
|
||||
}
|
||||
String fromName = firstEventLikePlaceholder(entryPoint.getName());
|
||||
if (fromName != null) {
|
||||
return fromName;
|
||||
}
|
||||
if (entryPoint.getMetadata() != null
|
||||
&& entryPoint.getMetadata().get("path") instanceof String path) {
|
||||
return firstEventLikePlaceholder(path);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean isPathOrQueryVariable(EntryPoint.Parameter parameter) {
|
||||
if (parameter == null || parameter.getAnnotations() == null) {
|
||||
return false;
|
||||
}
|
||||
return parameter.getAnnotations().stream().anyMatch(a ->
|
||||
a != null && (a.equals("PathVariable")
|
||||
|| a.endsWith(".PathVariable")
|
||||
|| a.equals("RequestParam")
|
||||
|| a.endsWith(".RequestParam")));
|
||||
}
|
||||
|
||||
private static boolean hasEventLikePlaceholder(String pathOrName) {
|
||||
return firstEventLikePlaceholder(pathOrName) != null;
|
||||
}
|
||||
|
||||
private static String firstEventLikePlaceholder(String pathOrName) {
|
||||
if (pathOrName == null || pathOrName.isBlank() || !pathOrName.contains("{")) {
|
||||
return null;
|
||||
}
|
||||
int start = 0;
|
||||
while (true) {
|
||||
int open = pathOrName.indexOf('{', start);
|
||||
if (open < 0) {
|
||||
return null;
|
||||
}
|
||||
int close = pathOrName.indexOf('}', open + 1);
|
||||
if (close < 0) {
|
||||
return null;
|
||||
}
|
||||
String placeholder = pathOrName.substring(open + 1, close).trim();
|
||||
int colon = placeholder.indexOf(':');
|
||||
if (colon > 0) {
|
||||
placeholder = placeholder.substring(0, colon);
|
||||
}
|
||||
if (EventCarrierPolicy.isCarrierParamName(placeholder)) {
|
||||
return placeholder;
|
||||
}
|
||||
start = close + 1;
|
||||
}
|
||||
}
|
||||
|
||||
private static String eventTypeFqn(TriggerPoint trigger, String machineEventTypeFqn) {
|
||||
if (trigger.getEventTypeFqn() != null && !trigger.getEventTypeFqn().isBlank()) {
|
||||
return trigger.getEventTypeFqn();
|
||||
}
|
||||
return machineEventTypeFqn;
|
||||
}
|
||||
|
||||
private static String constantLiteral(String constantFqn) {
|
||||
if (constantFqn == null || constantFqn.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
int lastDot = constantFqn.lastIndexOf('.');
|
||||
return lastDot >= 0 ? constantFqn.substring(lastDot + 1) : constantFqn;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package click.kamil.springstatemachineexporter.analysis.enricher;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.EntryPointKeys;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.LinkResolution;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.MatchedTransition;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||
@@ -103,14 +104,14 @@ public final class SemanticCallChainDedup {
|
||||
if (chain == null) {
|
||||
continue;
|
||||
}
|
||||
byEntry.computeIfAbsent(entryPointKey(chain), key -> new ArrayList<>()).add(chain);
|
||||
byEntry.computeIfAbsent(EntryPointKeys.of(chain), key -> new ArrayList<>()).add(chain);
|
||||
}
|
||||
return byEntry;
|
||||
}
|
||||
|
||||
static String semanticKey(CallChain chain) {
|
||||
StringBuilder key = new StringBuilder();
|
||||
key.append(entryPointKey(chain)).append('|');
|
||||
key.append(EntryPointKeys.of(chain)).append('|');
|
||||
key.append(triggerKey(chain.getTriggerPoint())).append('|');
|
||||
key.append(linkResolutionKey(chain.getLinkResolution())).append('|');
|
||||
key.append(matchedTransitionsKey(chain.getMatchedTransitions())).append('|');
|
||||
@@ -176,7 +177,7 @@ public final class SemanticCallChainDedup {
|
||||
if (entryHop.equals(firstHop)) {
|
||||
return true;
|
||||
}
|
||||
return normalizeMethodHopToEntryKey(firstHop)
|
||||
return EntryPointKeys.normalizeMethodHop(firstHop)
|
||||
.equals(entryPoint.getClassName() + "#" + entryPoint.getMethodName());
|
||||
}
|
||||
|
||||
@@ -198,38 +199,6 @@ public final class SemanticCallChainDedup {
|
||||
return chain.getMethodChain() == null ? Integer.MAX_VALUE : chain.getMethodChain().size();
|
||||
}
|
||||
|
||||
private static String entryPointKey(CallChain chain) {
|
||||
EntryPoint entryPoint = chain.getEntryPoint();
|
||||
if (entryPoint != null) {
|
||||
if (entryPoint.getName() != null && !entryPoint.getName().isBlank()) {
|
||||
return entryPoint.getName();
|
||||
}
|
||||
if (entryPoint.getClassName() != null && entryPoint.getMethodName() != null) {
|
||||
return entryPoint.getClassName() + "#" + entryPoint.getMethodName();
|
||||
}
|
||||
}
|
||||
List<String> methodChain = chain.getMethodChain();
|
||||
if (methodChain != null && !methodChain.isEmpty()) {
|
||||
return normalizeMethodHopToEntryKey(methodChain.get(0));
|
||||
}
|
||||
TriggerPoint trigger = chain.getTriggerPoint();
|
||||
if (trigger != null && trigger.getClassName() != null && trigger.getMethodName() != null) {
|
||||
return trigger.getClassName() + "#" + trigger.getMethodName();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private static String normalizeMethodHopToEntryKey(String hop) {
|
||||
if (hop == null || hop.isBlank()) {
|
||||
return "";
|
||||
}
|
||||
int lastDot = hop.lastIndexOf('.');
|
||||
if (lastDot <= 0 || lastDot >= hop.length() - 1) {
|
||||
return hop;
|
||||
}
|
||||
return hop.substring(0, lastDot) + "#" + hop.substring(lastDot + 1);
|
||||
}
|
||||
|
||||
private static String triggerKey(TriggerPoint trigger) {
|
||||
if (trigger == null) {
|
||||
return "";
|
||||
|
||||
@@ -63,10 +63,11 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
|
||||
context);
|
||||
String effectiveEventTypeFqn = effectiveMachineTypes.eventTypeFqn();
|
||||
|
||||
List<CallChain> workList = new ArrayList<>();
|
||||
for (CallChain chain : result.getMetadata().getCallChains()) {
|
||||
TriggerPoint tp = chain.getTriggerPoint();
|
||||
if (tp == null || tp.getEvent() == null) {
|
||||
updatedChains.add(chain);
|
||||
workList.add(chain);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -82,10 +83,23 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
|
||||
tp = TriggerMachineTypeRebinder.rebind(tp, chain.getMethodChain(), context);
|
||||
tp = CallChainLinkPolicy.applyRestExternalPolicy(tp, chain, context);
|
||||
tp = tp.toBuilder()
|
||||
.eventTypeFqn(concreteOrFallback(tp.getEventTypeFqn(), effectiveEventTypeFqn))
|
||||
.stateTypeFqn(concreteOrFallback(tp.getStateTypeFqn(), effectiveMachineTypes.stateTypeFqn()))
|
||||
.eventTypeFqn(SharedServiceRoutingPolicy.concreteOrFallback(
|
||||
tp.getEventTypeFqn(), effectiveEventTypeFqn))
|
||||
.stateTypeFqn(SharedServiceRoutingPolicy.concreteOrFallback(
|
||||
tp.getStateTypeFqn(), effectiveMachineTypes.stateTypeFqn()))
|
||||
.build();
|
||||
|
||||
workList.addAll(ExpandableEntryPolySplitter.maybeExpand(
|
||||
chain, tp, effectiveEventTypeFqn, context));
|
||||
}
|
||||
|
||||
for (CallChain chain : workList) {
|
||||
TriggerPoint tp = chain.getTriggerPoint();
|
||||
if (tp == null || tp.getEvent() == null) {
|
||||
updatedChains.add(chain);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (LifecycleTriggerMarkers.isLifecycle(tp.getEvent())) {
|
||||
updatedChains.add(chain);
|
||||
continue;
|
||||
@@ -431,21 +445,4 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
|
||||
return MachineEnumCanonicalizer.classifyTriggerEvent(trigger.getEvent())
|
||||
== MachineEnumCanonicalizer.TriggerEventKind.CANONICAL_ENUM;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefer a concrete trigger type; otherwise adopt the machine type only when it is also
|
||||
* concrete. Non-concrete machine types ({@code String}/{@code Object}/type-vars) must not be
|
||||
* stamped onto triggers — that makes multi-machine affinity treat them as ambiguous shared
|
||||
* generics and fail-closed, wiping package-proven String-machine chains (e.g. MavenOrder JMS).
|
||||
*/
|
||||
private static String concreteOrFallback(String triggerTypeFqn, String machineTypeFqn) {
|
||||
if (SharedServiceRoutingPolicy.isConcreteMachineType(triggerTypeFqn)) {
|
||||
return triggerTypeFqn;
|
||||
}
|
||||
if (SharedServiceRoutingPolicy.isConcreteMachineType(machineTypeFqn)) {
|
||||
return machineTypeFqn;
|
||||
}
|
||||
return triggerTypeFqn;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -52,7 +52,7 @@ public class StrictFqnMatchingEngine implements EventMatchingEngine {
|
||||
continue;
|
||||
}
|
||||
String eventTypeFqn = triggerPoint.getEventTypeFqn();
|
||||
if (pe.contains(".") && isStringTypeOrPrimitive(eventTypeFqn)) {
|
||||
if (pe.contains(".") && MachineEnumCanonicalizer.isStringOrPrimitiveEventType(eventTypeFqn)) {
|
||||
eventTypeFqn = null;
|
||||
}
|
||||
if (matchEventNames(pe, smEventRaw, eventTypeFqn)) {
|
||||
@@ -61,7 +61,7 @@ public class StrictFqnMatchingEngine implements EventMatchingEngine {
|
||||
if (!pe.contains(".") && smEventRaw.contains(".")) {
|
||||
String smConst = constantName(smEventRaw);
|
||||
if (pe.equals(smConst) && triggerPoint.getEventTypeFqn() != null
|
||||
&& !isStringTypeOrPrimitive(triggerPoint.getEventTypeFqn())) {
|
||||
&& !MachineEnumCanonicalizer.isStringOrPrimitiveEventType(triggerPoint.getEventTypeFqn())) {
|
||||
String smEnumType = smEventRaw.substring(0, smEventRaw.lastIndexOf('.'));
|
||||
return typesMatch(triggerPoint.getEventTypeFqn(), smEnumType);
|
||||
}
|
||||
@@ -73,7 +73,7 @@ public class StrictFqnMatchingEngine implements EventMatchingEngine {
|
||||
}
|
||||
|
||||
if (polyEvents.isEmpty() && isDynamicVariable(rawTriggerEvent)) {
|
||||
if (triggerPoint.getEventTypeFqn() != null && !isStringTypeOrPrimitive(triggerPoint.getEventTypeFqn())) {
|
||||
if (triggerPoint.getEventTypeFqn() != null && !MachineEnumCanonicalizer.isStringOrPrimitiveEventType(triggerPoint.getEventTypeFqn())) {
|
||||
return false;
|
||||
}
|
||||
// Getter/method-call expressions (e.g. richEvent.getId(), getType()) without resolved
|
||||
@@ -81,7 +81,7 @@ public class StrictFqnMatchingEngine implements EventMatchingEngine {
|
||||
if (rawTriggerEvent.contains(".") || rawTriggerEvent.endsWith("()")) {
|
||||
return false;
|
||||
}
|
||||
if (triggerPoint.getEventTypeFqn() != null && isStringTypeOrPrimitive(triggerPoint.getEventTypeFqn())) {
|
||||
if (triggerPoint.getEventTypeFqn() != null && MachineEnumCanonicalizer.isStringOrPrimitiveEventType(triggerPoint.getEventTypeFqn())) {
|
||||
return !smEventRaw.contains(".");
|
||||
}
|
||||
return !smEventRaw.contains(".");
|
||||
@@ -100,22 +100,22 @@ public class StrictFqnMatchingEngine implements EventMatchingEngine {
|
||||
if (!tConst.equals(smConst)) return false;
|
||||
|
||||
// String-typed triggers may match configured string transition events by constant name.
|
||||
if (isStringTypeOrPrimitive(eventTypeFqn) && smEvent.contains(".") && !triggerEvent.contains(".")) {
|
||||
if (MachineEnumCanonicalizer.isStringOrPrimitiveEventType(eventTypeFqn) && smEvent.contains(".") && !triggerEvent.contains(".")) {
|
||||
String smEnumType = smEvent.substring(0, smEvent.lastIndexOf('.'));
|
||||
return isStringTypeOrPrimitive(smEnumType);
|
||||
return MachineEnumCanonicalizer.isStringOrPrimitiveEventType(smEnumType);
|
||||
}
|
||||
|
||||
// Trigger scraped as String.CONST while config still stores the bare literal constant.
|
||||
if (isStringTypeOrPrimitive(eventTypeFqn) && triggerEvent.contains(".") && !smEvent.contains(".")) {
|
||||
if (MachineEnumCanonicalizer.isStringOrPrimitiveEventType(eventTypeFqn) && triggerEvent.contains(".") && !smEvent.contains(".")) {
|
||||
String triggerEnumType = triggerEvent.substring(0, triggerEvent.lastIndexOf('.'));
|
||||
return isStringTypeOrPrimitive(triggerEnumType);
|
||||
return MachineEnumCanonicalizer.isStringOrPrimitiveEventType(triggerEnumType);
|
||||
}
|
||||
|
||||
// Qualified trigger against bare SM event without eventTypeFqn: only when the qualifier
|
||||
// itself is a string/primitive type (not a real enum like OrderEvent.PAY ↔ PAY).
|
||||
if (eventTypeFqn == null && !smEvent.contains(".") && triggerEvent.contains(".")) {
|
||||
String triggerEnumType = triggerEvent.substring(0, triggerEvent.lastIndexOf('.'));
|
||||
return isStringTypeOrPrimitive(triggerEnumType);
|
||||
return MachineEnumCanonicalizer.isStringOrPrimitiveEventType(triggerEnumType);
|
||||
}
|
||||
|
||||
if (smEvent.contains(".")) {
|
||||
@@ -154,12 +154,6 @@ public class StrictFqnMatchingEngine implements EventMatchingEngine {
|
||||
return event.contains(".") ? event.substring(event.lastIndexOf('.') + 1) : event;
|
||||
}
|
||||
|
||||
private boolean isStringTypeOrPrimitive(String typeFqn) {
|
||||
if (typeFqn == null) return false;
|
||||
return typeFqn.equals("String") || typeFqn.equals("java.lang.String") ||
|
||||
typeFqn.equals("int") || typeFqn.equals("long") || typeFqn.equals("char");
|
||||
}
|
||||
|
||||
private boolean isDynamicVariable(String eventStr) {
|
||||
if (eventStr == null || eventStr.isEmpty()) return false;
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import click.kamil.springstatemachineexporter.analysis.enricher.matching.EventMa
|
||||
import click.kamil.springstatemachineexporter.analysis.enricher.matching.StrictFqnMatchingEngine;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
|
||||
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import click.kamil.springstatemachineexporter.model.Transition;
|
||||
@@ -278,11 +279,7 @@ public class HeuristicBeanResolutionEngine implements BeanResolutionEngine {
|
||||
}
|
||||
|
||||
private boolean isStringType(String typeFqn) {
|
||||
if (typeFqn == null) {
|
||||
return false;
|
||||
}
|
||||
String erased = eraseGenerics(typeFqn);
|
||||
return "java.lang.String".equals(erased) || "String".equals(erased);
|
||||
return MachineEnumCanonicalizer.isJavaStringType(typeFqn);
|
||||
}
|
||||
|
||||
private Boolean resolveNamedBeanTarget(String beanName, String currentMachineName, CodebaseContext context) {
|
||||
|
||||
@@ -47,8 +47,28 @@ public final class SharedServiceRoutingPolicy {
|
||||
if (erased.length() == 1 && Character.isUpperCase(erased.charAt(0))) {
|
||||
return false;
|
||||
}
|
||||
return !"java.lang.Object".equals(erased) && !"Object".equals(erased)
|
||||
&& !"java.io.Serializable".equals(erased) && !"Serializable".equals(erased)
|
||||
&& !"java.lang.String".equals(erased) && !"String".equals(erased);
|
||||
return !"java.io.Serializable".equals(erased) && !"Serializable".equals(erased)
|
||||
&& !isNonConcreteMachineErasure(erased);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefer a concrete trigger type; otherwise adopt the machine type only when it is also
|
||||
* concrete. Non-concrete machine types ({@code String}/{@code Object}/type-vars) must not be
|
||||
* stamped onto triggers — that makes multi-machine affinity treat them as ambiguous shared
|
||||
* generics and fail-closed, wiping package-proven String-machine chains.
|
||||
*/
|
||||
public static String concreteOrFallback(String triggerTypeFqn, String machineTypeFqn) {
|
||||
if (isConcreteMachineType(triggerTypeFqn)) {
|
||||
return triggerTypeFqn;
|
||||
}
|
||||
if (isConcreteMachineType(machineTypeFqn)) {
|
||||
return machineTypeFqn;
|
||||
}
|
||||
return triggerTypeFqn;
|
||||
}
|
||||
|
||||
private static boolean isNonConcreteMachineErasure(String erased) {
|
||||
return "java.lang.Object".equals(erased) || "Object".equals(erased)
|
||||
|| "java.lang.String".equals(erased) || "String".equals(erased);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.model;
|
||||
|
||||
/**
|
||||
* Stable identity keys for {@link EntryPoint} used by scoping, merge, and semantic dedup.
|
||||
*/
|
||||
public final class EntryPointKeys {
|
||||
|
||||
private EntryPointKeys() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefer human-readable entry name (REST path / topic); otherwise {@code class#method}.
|
||||
*/
|
||||
public static String of(EntryPoint entryPoint) {
|
||||
if (entryPoint == null) {
|
||||
return "";
|
||||
}
|
||||
if (entryPoint.getName() != null && !entryPoint.getName().isBlank()) {
|
||||
return entryPoint.getName();
|
||||
}
|
||||
if (entryPoint.getClassName() != null && entryPoint.getMethodName() != null) {
|
||||
return entryPoint.getClassName() + "#" + entryPoint.getMethodName();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Entry identity for a call chain: entry point when present, otherwise first hop / trigger site.
|
||||
*/
|
||||
public static String of(CallChain chain) {
|
||||
if (chain == null) {
|
||||
return "";
|
||||
}
|
||||
String fromEntry = of(chain.getEntryPoint());
|
||||
if (!fromEntry.isEmpty()) {
|
||||
return fromEntry;
|
||||
}
|
||||
if (chain.getMethodChain() != null && !chain.getMethodChain().isEmpty()) {
|
||||
return normalizeMethodHop(chain.getMethodChain().get(0));
|
||||
}
|
||||
TriggerPoint trigger = chain.getTriggerPoint();
|
||||
if (trigger != null && trigger.getClassName() != null && trigger.getMethodName() != null) {
|
||||
return trigger.getClassName() + "#" + trigger.getMethodName();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/** Converts {@code com.example.Foo.bar} hop form to {@code com.example.Foo#bar}. */
|
||||
public static String normalizeMethodHop(String hop) {
|
||||
if (hop == null || hop.isBlank()) {
|
||||
return "";
|
||||
}
|
||||
int lastDot = hop.lastIndexOf('.');
|
||||
if (lastDot <= 0 || lastDot >= hop.length() - 1) {
|
||||
return hop;
|
||||
}
|
||||
return hop.substring(0, lastDot) + "#" + hop.substring(lastDot + 1);
|
||||
}
|
||||
}
|
||||
@@ -714,6 +714,34 @@ public final class MachineEnumCanonicalizer {
|
||||
|| typeFqn.equals("java.lang.Object") || typeFqn.equals("Object");
|
||||
}
|
||||
|
||||
/**
|
||||
* String / primitive event-type markers used by strict matching. Unlike
|
||||
* {@link #isStringOrPrimitiveType}, does <em>not</em> treat {@code Object} as string-typed —
|
||||
* that would let bare constants match against Object-erased triggers too broadly.
|
||||
*/
|
||||
public static boolean isStringOrPrimitiveEventType(String typeFqn) {
|
||||
if (typeFqn == null) {
|
||||
return false;
|
||||
}
|
||||
return typeFqn.equals("String") || typeFqn.equals("java.lang.String")
|
||||
|| typeFqn.equals("int") || typeFqn.equals("long") || typeFqn.equals("char");
|
||||
}
|
||||
|
||||
/** True for {@code String} / {@code java.lang.String} (optionally with generics erased). */
|
||||
public static boolean isJavaStringType(String typeFqn) {
|
||||
if (typeFqn == null || typeFqn.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
String erased = typeFqn;
|
||||
int generic = erased.indexOf('<');
|
||||
if (generic >= 0) {
|
||||
erased = erased.substring(0, generic);
|
||||
}
|
||||
int lastDot = erased.lastIndexOf('.');
|
||||
String simple = lastDot >= 0 ? erased.substring(lastDot + 1) : erased;
|
||||
return "String".equals(simple);
|
||||
}
|
||||
|
||||
private static boolean isEnumConstantCandidate(String eventStr) {
|
||||
if (eventStr == null) {
|
||||
return false;
|
||||
|
||||
@@ -221,11 +221,7 @@ public final class EntryPointBindingExpander {
|
||||
if (containsBooleanTernaryCondition(methodFqn, paramName, context)) {
|
||||
return true;
|
||||
}
|
||||
List<CallEdge> edges = callGraph.get(methodFqn);
|
||||
if (edges == null) {
|
||||
continue;
|
||||
}
|
||||
for (CallEdge edge : edges) {
|
||||
for (CallEdge edge : edgesForMethod(methodFqn, callGraph, context)) {
|
||||
if (edge.getTargetMethod() != null) {
|
||||
queue.add(edge.getTargetMethod());
|
||||
}
|
||||
@@ -418,6 +414,93 @@ public final class EntryPointBindingExpander {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
/**
|
||||
* Call-graph keys are declaring-type FQNs. When analysis starts from a subclass or interface
|
||||
* method that is not redeclared on that type, look up edges on superclass / implementation
|
||||
* method FQNs so parameter aliases can reach nested {@code Enum.valueOf} sites.
|
||||
*/
|
||||
private static List<CallEdge> edgesForMethod(
|
||||
String methodFqn,
|
||||
Map<String, List<CallEdge>> callGraph,
|
||||
CodebaseContext context) {
|
||||
if (methodFqn == null || callGraph == null) {
|
||||
return List.of();
|
||||
}
|
||||
List<CallEdge> direct = callGraph.get(methodFqn);
|
||||
if (direct != null && !direct.isEmpty()) {
|
||||
return direct;
|
||||
}
|
||||
if (context == null || !methodFqn.contains(".")) {
|
||||
return List.of();
|
||||
}
|
||||
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
|
||||
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
|
||||
TypeDeclaration typeDeclaration = context.getTypeDeclaration(className);
|
||||
Set<String> visited = new HashSet<>();
|
||||
while (typeDeclaration != null) {
|
||||
String typeFqn = context.getFqn(typeDeclaration);
|
||||
if (typeFqn == null || !visited.add(typeFqn)) {
|
||||
break;
|
||||
}
|
||||
String superclass = context.getSuperclassFqn(typeDeclaration);
|
||||
if (superclass == null || superclass.isBlank()) {
|
||||
break;
|
||||
}
|
||||
String superMethod = superclass + "." + methodName;
|
||||
List<CallEdge> inherited = callGraph.get(superMethod);
|
||||
if (inherited != null && !inherited.isEmpty()) {
|
||||
return inherited;
|
||||
}
|
||||
typeDeclaration = context.getTypeDeclaration(superclass);
|
||||
}
|
||||
List<CallEdge> fromImplementations = edgesFromInterfaceImplementations(
|
||||
className, methodName, callGraph, context);
|
||||
if (!fromImplementations.isEmpty()) {
|
||||
return fromImplementations;
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
|
||||
/**
|
||||
* When the entry/method FQN is an interface with no graph edges, use concrete implementors
|
||||
* (same idea as {@code CallGraphPathFinder}). Limited to interfaces — applying this to every
|
||||
* class with empty edges fans out across deep subclass trees and can explode binding BFS.
|
||||
*/
|
||||
private static List<CallEdge> edgesFromInterfaceImplementations(
|
||||
String typeFqn,
|
||||
String methodName,
|
||||
Map<String, List<CallEdge>> callGraph,
|
||||
CodebaseContext context) {
|
||||
if (typeFqn == null || methodName == null || context == null) {
|
||||
return List.of();
|
||||
}
|
||||
TypeDeclaration typeDeclaration = context.getTypeDeclaration(typeFqn);
|
||||
if (typeDeclaration == null || !typeDeclaration.isInterface()) {
|
||||
return List.of();
|
||||
}
|
||||
List<String> implementations = context.getImplementations(typeFqn);
|
||||
if (implementations == null || implementations.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
// Fail closed when multiple implementors contribute edges — merging mixed valueOf sites
|
||||
// invents binding variants that no single controller path can reach.
|
||||
List<CallEdge> soleImplEdges = null;
|
||||
for (String impl : implementations) {
|
||||
if (impl == null || impl.isBlank() || impl.equals(typeFqn)) {
|
||||
continue;
|
||||
}
|
||||
List<CallEdge> edges = callGraph.get(impl + "." + methodName);
|
||||
if (edges == null || edges.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
if (soleImplEdges != null) {
|
||||
return List.of();
|
||||
}
|
||||
soleImplEdges = edges;
|
||||
}
|
||||
return soleImplEdges == null ? List.of() : soleImplEdges;
|
||||
}
|
||||
|
||||
private static List<String> bindingNamesForParameter(MethodDeclaration method, String paramName) {
|
||||
if (paramName == null || paramName.isBlank()) {
|
||||
return List.of();
|
||||
@@ -492,10 +575,12 @@ public final class EntryPointBindingExpander {
|
||||
Map<String, List<CallEdge>> callGraph,
|
||||
Map<String, String> partialBindings,
|
||||
ArrayDeque<ParameterAliasState> queue) {
|
||||
List<CallEdge> edges = callGraph.get(state.methodFqn());
|
||||
if (edges == null) {
|
||||
List<CallEdge> edges = edgesForMethod(state.methodFqn(), callGraph, context);
|
||||
if (edges.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
MethodDeclaration callerMethod = findMethodDeclaration(state.methodFqn(), context);
|
||||
List<String> forwardedNames = bindingNamesForParameter(callerMethod, state.paramName());
|
||||
for (CallEdge edge : edges) {
|
||||
if (edge.getTargetMethod() == null
|
||||
|| !isEdgeCompatibleWithBindings(edge, partialBindings, state.methodFqn(), context)) {
|
||||
@@ -511,7 +596,7 @@ public final class EntryPointBindingExpander {
|
||||
}
|
||||
int limit = Math.min(args.size(), targetParamNames.size());
|
||||
for (int i = 0; i < limit; i++) {
|
||||
if (!argumentForwardsParameter(args.get(i), state.paramName())) {
|
||||
if (!argumentForwardsAnyParameter(args.get(i), forwardedNames)) {
|
||||
continue;
|
||||
}
|
||||
MethodDeclaration targetMethod = findMethodDeclaration(edge.getTargetMethod(), context);
|
||||
@@ -541,6 +626,18 @@ public final class EntryPointBindingExpander {
|
||||
return BooleanConstraintEvaluator.isCompatibleWithBindings(constraint, partialBindings);
|
||||
}
|
||||
|
||||
private static boolean argumentForwardsAnyParameter(String argument, List<String> paramNames) {
|
||||
if (argument == null || paramNames == null || paramNames.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
for (String paramName : paramNames) {
|
||||
if (argumentForwardsParameter(argument, paramName)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean argumentForwardsParameter(String argument, String paramName) {
|
||||
if (argument == null || paramName == null) {
|
||||
return false;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.service;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
|
||||
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
@@ -232,10 +233,6 @@ public final class EventCarrierPolicy {
|
||||
}
|
||||
|
||||
private static boolean isStringType(String type) {
|
||||
if (type == null) {
|
||||
return false;
|
||||
}
|
||||
String simple = type.contains(".") ? type.substring(type.lastIndexOf('.') + 1) : type;
|
||||
return "String".equals(simple);
|
||||
return MachineEnumCanonicalizer.isJavaStringType(type);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import click.kamil.springstatemachineexporter.analysis.model.LibraryHint;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||
import click.kamil.springstatemachineexporter.analysis.pipeline.ReactiveExpressionSupport;
|
||||
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
|
||||
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.eclipse.jdt.core.dom.*;
|
||||
@@ -836,9 +837,7 @@ public class GenericEventDetector {
|
||||
if (type == null) {
|
||||
return false;
|
||||
}
|
||||
String raw = type.toString();
|
||||
String simple = raw.contains(".") ? raw.substring(raw.lastIndexOf('.') + 1) : raw;
|
||||
return "String".equals(simple);
|
||||
return MachineEnumCanonicalizer.isJavaStringType(type.toString());
|
||||
}
|
||||
|
||||
private static boolean hasPayloadAnnotation(SingleVariableDeclaration param) {
|
||||
|
||||
@@ -407,7 +407,52 @@ class CallChainPolyNarrowerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFailClosedWhenWidePolyHasNoEndpointSpecificHint() {
|
||||
void expandableUnboundEventOnlyTemplateSplitsTrustedValueOfWiden() {
|
||||
// P2: expandable {event} alone + unbound trusted Enum.valueOf multi-poly → split.
|
||||
Transition pay = transition("NEW", "PAID", "com.example.OrderEvent.PAY");
|
||||
Transition ship = transition("PAID", "SHIPPED", "com.example.OrderEvent.SHIP");
|
||||
|
||||
CallChain chain = CallChain.builder()
|
||||
.entryPoint(EntryPoint.builder()
|
||||
.name("POST /api/machine/transition/{event}")
|
||||
.className("com.example.MachineController")
|
||||
.methodName("transition")
|
||||
.build())
|
||||
.triggerPoint(TriggerPoint.builder()
|
||||
.ambiguous(true)
|
||||
.external(true)
|
||||
.event("OrderEvent.valueOf(eventStr)")
|
||||
.polymorphicEvents(List.of(
|
||||
"com.example.OrderEvent.PAY",
|
||||
"com.example.OrderEvent.SHIP"))
|
||||
.build())
|
||||
.build();
|
||||
|
||||
AnalysisResult result = AnalysisResult.builder()
|
||||
.name("com.example.config.OrderStateMachineConfig")
|
||||
.eventTypeFqn("com.example.OrderEvent")
|
||||
.stateTypeFqn("com.example.OrderState")
|
||||
.transitions(List.of(pay, ship))
|
||||
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
|
||||
.build();
|
||||
|
||||
enricher.enrich(result, null, null);
|
||||
|
||||
List<CallChain> linked = result.getMetadata().getCallChains();
|
||||
assertThat(linked).hasSize(2);
|
||||
assertThat(linked)
|
||||
.allSatisfy(c -> assertThat(c.getLinkResolution()).isEqualTo(LinkResolution.RESOLVED));
|
||||
assertThat(linked)
|
||||
.anySatisfy(c -> assertThat(c.getMatchedTransitions())
|
||||
.anyMatch(mt -> "com.example.OrderEvent.PAY".equals(mt.getEvent())));
|
||||
assertThat(linked)
|
||||
.anySatisfy(c -> assertThat(c.getMatchedTransitions())
|
||||
.anyMatch(mt -> "com.example.OrderEvent.SHIP".equals(mt.getEvent())));
|
||||
}
|
||||
|
||||
@Test
|
||||
void dualUnboundMachineAndEventTemplateStaysFailClosed() {
|
||||
// P2 must not fan out events while {machineType} is still unbound.
|
||||
Transition pay = transition("NEW", "PAID", "com.example.OrderEvent.PAY");
|
||||
Transition ship = transition("PAID", "SHIPPED", "com.example.OrderEvent.SHIP");
|
||||
|
||||
@@ -444,6 +489,79 @@ class CallChainPolyNarrowerTest {
|
||||
LinkResolution.UNRESOLVED_EXTERNAL);
|
||||
}
|
||||
|
||||
@Test
|
||||
void dualTemplateWithBoundMachineTypeStillSplitsEventWiden() {
|
||||
Transition pay = transition("NEW", "PAID", "com.example.OrderEvent.PAY");
|
||||
Transition ship = transition("PAID", "SHIPPED", "com.example.OrderEvent.SHIP");
|
||||
|
||||
CallChain chain = CallChain.builder()
|
||||
.entryPoint(EntryPoint.builder()
|
||||
.name("POST /api/machine/{machineType}/transition/{event}")
|
||||
.className("com.example.MachineController")
|
||||
.methodName("transition")
|
||||
.build())
|
||||
.pathBindings(java.util.Map.of("machineType", "ORDER"))
|
||||
.triggerPoint(TriggerPoint.builder()
|
||||
.ambiguous(true)
|
||||
.external(true)
|
||||
.event("OrderEvent.valueOf(eventStr)")
|
||||
.polymorphicEvents(List.of(
|
||||
"com.example.OrderEvent.PAY",
|
||||
"com.example.OrderEvent.SHIP"))
|
||||
.build())
|
||||
.build();
|
||||
|
||||
AnalysisResult result = AnalysisResult.builder()
|
||||
.name("com.example.config.OrderStateMachineConfig")
|
||||
.eventTypeFqn("com.example.OrderEvent")
|
||||
.stateTypeFqn("com.example.OrderState")
|
||||
.transitions(List.of(pay, ship))
|
||||
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
|
||||
.build();
|
||||
|
||||
enricher.enrich(result, null, null);
|
||||
|
||||
List<CallChain> linked = result.getMetadata().getCallChains();
|
||||
assertThat(linked).hasSize(2);
|
||||
assertThat(linked)
|
||||
.allSatisfy(c -> assertThat(c.getLinkResolution()).isEqualTo(LinkResolution.RESOLVED));
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonExpandableWidePolyWithoutEventCarrierStaysFailClosed() {
|
||||
Transition pay = transition("NEW", "PAID", "com.example.OrderEvent.PAY");
|
||||
Transition ship = transition("PAID", "SHIPPED", "com.example.OrderEvent.SHIP");
|
||||
|
||||
CallChain chain = CallChain.builder()
|
||||
.entryPoint(EntryPoint.builder()
|
||||
.name("POST /api/machine/order/adjust")
|
||||
.className("com.example.MachineController")
|
||||
.methodName("adjust")
|
||||
.build())
|
||||
.triggerPoint(TriggerPoint.builder()
|
||||
.ambiguous(true)
|
||||
.event("OrderEvent.valueOf(eventStr)")
|
||||
.polymorphicEvents(List.of(
|
||||
"com.example.OrderEvent.PAY",
|
||||
"com.example.OrderEvent.SHIP"))
|
||||
.build())
|
||||
.build();
|
||||
|
||||
AnalysisResult result = AnalysisResult.builder()
|
||||
.name("com.example.config.OrderStateMachineConfig")
|
||||
.eventTypeFqn("com.example.OrderEvent")
|
||||
.stateTypeFqn("com.example.OrderState")
|
||||
.transitions(List.of(pay, ship))
|
||||
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
|
||||
.build();
|
||||
|
||||
enricher.enrich(result, null, null);
|
||||
|
||||
CallChain linked = result.getMetadata().getCallChains().get(0);
|
||||
assertThat(linked.getMatchedTransitions()).isNullOrEmpty();
|
||||
assertThat(linked.getLinkResolution()).isEqualTo(LinkResolution.AMBIGUOUS_WIDEN);
|
||||
}
|
||||
|
||||
private static Transition transition(String source, String target, String eventFqn) {
|
||||
Transition transition = new Transition();
|
||||
transition.setSourceStates(List.of(State.of(source, "com.example.OrderState." + source)));
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.enricher;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.LinkResolution;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||
import click.kamil.springstatemachineexporter.model.Event;
|
||||
import click.kamil.springstatemachineexporter.model.State;
|
||||
import click.kamil.springstatemachineexporter.model.Transition;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* P2: expandable event-carrier entries with unbound {@code valueOf} multi-poly must split into
|
||||
* one chain per constant; non-expandable shared entries must stay fail-closed.
|
||||
*/
|
||||
class ExpandableEntryPolySplitterTest {
|
||||
|
||||
private final TransitionLinkerEnricher enricher = new TransitionLinkerEnricher();
|
||||
|
||||
@Test
|
||||
void maybeExpandSplitsTrustedMultiPolyForExpandableEventCarrier() {
|
||||
CallChain chain = expandableCarrierChain(null);
|
||||
TriggerPoint prepared = chain.getTriggerPoint();
|
||||
|
||||
List<CallChain> split = ExpandableEntryPolySplitter.maybeExpand(
|
||||
chain, prepared, "com.example.OrderEvent", null);
|
||||
|
||||
assertThat(split).hasSize(2);
|
||||
assertThat(split)
|
||||
.extracting(c -> c.getTriggerPoint().getPolymorphicEvents())
|
||||
.containsExactlyInAnyOrder(
|
||||
List.of("com.example.OrderEvent.PAY"),
|
||||
List.of("com.example.OrderEvent.SHIP"));
|
||||
assertThat(split)
|
||||
.allSatisfy(c -> {
|
||||
assertThat(c.getTriggerPoint().isAmbiguous()).isFalse();
|
||||
assertThat(c.getTriggerPoint().isExternal()).isFalse();
|
||||
assertThat(c.getPathBindings()).containsKey("event");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void maybeExpandDoesNotSplitWhenCarrierAlreadyBound() {
|
||||
CallChain chain = expandableCarrierChain(Map.of("event", "PAY"));
|
||||
List<CallChain> out = ExpandableEntryPolySplitter.maybeExpand(
|
||||
chain, chain.getTriggerPoint(), "com.example.OrderEvent", null);
|
||||
assertThat(out).hasSize(1);
|
||||
assertThat(out.get(0).getTriggerPoint().getPolymorphicEvents()).hasSize(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void maybeExpandDoesNotSplitWhenMachineDiscriminatorUnbound() {
|
||||
CallChain chain = CallChain.builder()
|
||||
.entryPoint(EntryPoint.builder()
|
||||
.type(EntryPoint.Type.REST)
|
||||
.name("POST /api/machine/{machineType}/transition/{event}")
|
||||
.className("com.example.MachineController")
|
||||
.methodName("transition")
|
||||
.parameters(List.of(
|
||||
EntryPoint.Parameter.builder()
|
||||
.name("machineType")
|
||||
.type("String")
|
||||
.annotations(List.of("PathVariable"))
|
||||
.build(),
|
||||
EntryPoint.Parameter.builder()
|
||||
.name("event")
|
||||
.type("String")
|
||||
.annotations(List.of("PathVariable"))
|
||||
.build()))
|
||||
.build())
|
||||
.triggerPoint(multiValueOfTrigger())
|
||||
.build();
|
||||
|
||||
assertThat(ExpandableEntryPolySplitter.shouldSplit(
|
||||
chain, chain.getTriggerPoint(), "com.example.OrderEvent", null)).isFalse();
|
||||
assertThat(ExpandableEntryPolySplitter.maybeExpand(
|
||||
chain, chain.getTriggerPoint(), "com.example.OrderEvent", null)).hasSize(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void maybeExpandSplitsWhenMachineDiscriminatorAlreadyBound() {
|
||||
CallChain chain = CallChain.builder()
|
||||
.entryPoint(EntryPoint.builder()
|
||||
.type(EntryPoint.Type.REST)
|
||||
.name("POST /api/machine/{machineType}/transition/{event}")
|
||||
.className("com.example.MachineController")
|
||||
.methodName("transition")
|
||||
.build())
|
||||
.pathBindings(Map.of("machineType", "ORDER"))
|
||||
.triggerPoint(multiValueOfTrigger())
|
||||
.build();
|
||||
|
||||
List<CallChain> split = ExpandableEntryPolySplitter.maybeExpand(
|
||||
chain, chain.getTriggerPoint(), "com.example.OrderEvent", null);
|
||||
assertThat(split).hasSize(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void maybeExpandDoesNotSplitNonExpandableSharedEntry() {
|
||||
CallChain chain = CallChain.builder()
|
||||
.entryPoint(EntryPoint.builder()
|
||||
.type(EntryPoint.Type.REST)
|
||||
.name("POST /api/order/adjust")
|
||||
.className("com.example.OrderController")
|
||||
.methodName("adjust")
|
||||
.build())
|
||||
.triggerPoint(multiValueOfTrigger())
|
||||
.build();
|
||||
|
||||
List<CallChain> out = ExpandableEntryPolySplitter.maybeExpand(
|
||||
chain, chain.getTriggerPoint(), "com.example.OrderEvent", null);
|
||||
assertThat(out).hasSize(1);
|
||||
assertThat(ExpandableEntryPolySplitter.shouldSplit(
|
||||
chain, chain.getTriggerPoint(), "com.example.OrderEvent", null)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void linkerSplitsExpandableUnboundValueOfIntoResolvedChains() {
|
||||
Transition pay = transition("NEW", "PAID", "PAY");
|
||||
Transition ship = transition("PAID", "SHIPPED", "SHIP");
|
||||
|
||||
CallChain chain = expandableCarrierChain(null).toBuilder()
|
||||
.triggerPoint(multiValueOfTrigger().toBuilder().external(true).build())
|
||||
.build();
|
||||
|
||||
AnalysisResult result = AnalysisResult.builder()
|
||||
.name("OrderStateMachineConfig")
|
||||
.eventTypeFqn("com.example.OrderEvent")
|
||||
.stateTypeFqn("com.example.OrderState")
|
||||
.transitions(List.of(pay, ship))
|
||||
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
|
||||
.build();
|
||||
|
||||
enricher.enrich(result, null, null);
|
||||
|
||||
List<CallChain> linked = result.getMetadata().getCallChains();
|
||||
assertThat(linked).hasSize(2);
|
||||
assertThat(linked)
|
||||
.allSatisfy(c -> assertThat(c.getLinkResolution()).isEqualTo(LinkResolution.RESOLVED));
|
||||
assertThat(linked)
|
||||
.anySatisfy(c -> assertThat(c.getMatchedTransitions())
|
||||
.anyMatch(mt -> mt.getEvent() != null && mt.getEvent().contains("PAY")));
|
||||
assertThat(linked)
|
||||
.anySatisfy(c -> assertThat(c.getMatchedTransitions())
|
||||
.anyMatch(mt -> mt.getEvent() != null && mt.getEvent().contains("SHIP")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void linkerKeepsAmbiguousWidenForNonExpandableSharedValueOf() {
|
||||
Transition pay = transition("NEW", "PAID", "PAY");
|
||||
Transition ship = transition("PAID", "SHIPPED", "SHIP");
|
||||
|
||||
CallChain chain = CallChain.builder()
|
||||
.entryPoint(EntryPoint.builder()
|
||||
.type(EntryPoint.Type.REST)
|
||||
.name("POST /api/order/adjust")
|
||||
.className("com.example.OrderController")
|
||||
.methodName("adjust")
|
||||
.build())
|
||||
.triggerPoint(multiValueOfTrigger())
|
||||
.build();
|
||||
|
||||
AnalysisResult result = AnalysisResult.builder()
|
||||
.name("OrderStateMachineConfig")
|
||||
.eventTypeFqn("com.example.OrderEvent")
|
||||
.stateTypeFqn("com.example.OrderState")
|
||||
.transitions(List.of(pay, ship))
|
||||
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
|
||||
.build();
|
||||
|
||||
enricher.enrich(result, null, null);
|
||||
|
||||
assertThat(result.getMetadata().getCallChains())
|
||||
.hasSize(1)
|
||||
.allSatisfy(c -> {
|
||||
assertThat(c.getLinkResolution()).isEqualTo(LinkResolution.AMBIGUOUS_WIDEN);
|
||||
assertThat(c.getMatchedTransitions()).isNullOrEmpty();
|
||||
});
|
||||
}
|
||||
|
||||
private static CallChain expandableCarrierChain(Map<String, String> pathBindings) {
|
||||
return CallChain.builder()
|
||||
.entryPoint(EntryPoint.builder()
|
||||
.type(EntryPoint.Type.REST)
|
||||
.name("POST /api/machine/transition/{event}")
|
||||
.className("com.example.MachineController")
|
||||
.methodName("transition")
|
||||
.parameters(List.of(EntryPoint.Parameter.builder()
|
||||
.name("event")
|
||||
.type("String")
|
||||
.annotations(List.of("PathVariable"))
|
||||
.build()))
|
||||
.build())
|
||||
.pathBindings(pathBindings)
|
||||
.triggerPoint(multiValueOfTrigger())
|
||||
.build();
|
||||
}
|
||||
|
||||
private static TriggerPoint multiValueOfTrigger() {
|
||||
return TriggerPoint.builder()
|
||||
.ambiguous(true)
|
||||
.eventTypeFqn("com.example.OrderEvent")
|
||||
.event("OrderEvent.valueOf(eventStr)")
|
||||
.polymorphicEvents(List.of(
|
||||
"com.example.OrderEvent.PAY",
|
||||
"com.example.OrderEvent.SHIP"))
|
||||
.build();
|
||||
}
|
||||
|
||||
private static Transition transition(String from, String to, String event) {
|
||||
Transition t = new Transition();
|
||||
t.setSourceStates(List.of(State.of(from, "com.example.OrderState." + from)));
|
||||
t.setTargetStates(List.of(State.of(to, "com.example.OrderState." + to)));
|
||||
t.setEvent(Event.of(event, "com.example.OrderEvent." + event));
|
||||
return t;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.service;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.enricher.TransitionLinkerEnricher;
|
||||
import click.kamil.springstatemachineexporter.analysis.enricher.TriggerCanonicalizationEnricher;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.LinkResolution;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import click.kamil.springstatemachineexporter.model.Event;
|
||||
import click.kamil.springstatemachineexporter.model.State;
|
||||
import click.kamil.springstatemachineexporter.model.Transition;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Expandable REST {@code {event}} entries that reach nested {@code Enum.valueOf} through an
|
||||
* abstract collaborator must produce per-constant chains / bindings so linking is not stuck on
|
||||
* {@code AMBIGUOUS_WIDEN}. No project-specific type names.
|
||||
*/
|
||||
class DeepDispatcherExpandableEventBindingTest {
|
||||
|
||||
@Test
|
||||
void expandableEventPathShouldResolveThroughAbstractCollaboratorValueOf(@TempDir Path tempDir)
|
||||
throws Exception {
|
||||
writeFixture(tempDir);
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.setResolveBindings(true);
|
||||
context.scan(tempDir);
|
||||
|
||||
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
|
||||
EntryPoint entry = EntryPoint.builder()
|
||||
.type(EntryPoint.Type.REST)
|
||||
.name("POST /api/machine/transition/{event}")
|
||||
.className("com.example.MachineController")
|
||||
.methodName("transition")
|
||||
.parameters(List.of(EntryPoint.Parameter.builder()
|
||||
.name("event")
|
||||
.type("String")
|
||||
.annotations(List.of("PathVariable"))
|
||||
.build()))
|
||||
.build();
|
||||
TriggerPoint trigger = TriggerPoint.builder()
|
||||
.className("com.example.StateMachine")
|
||||
.methodName("sendEvent")
|
||||
.event("ev")
|
||||
.build();
|
||||
|
||||
List<Map<String, String>> variants =
|
||||
EntryPointBindingExpander.expandPathVariableBindings(entry, context, engine.buildCallGraph());
|
||||
assertThat(variants)
|
||||
.as("expander must reach nested abstract valueOf and emit one variant per enum constant")
|
||||
.extracting(map -> map.get("event"))
|
||||
.containsExactlyInAnyOrder("PAY", "SHIP", "CANCEL");
|
||||
|
||||
List<CallChain> chains = engine.findChains(List.of(entry), List.of(trigger));
|
||||
assertThat(chains).isNotEmpty();
|
||||
assertThat(chains)
|
||||
.anySatisfy(chain -> assertThat(chain.getPathBindings()).containsEntry("event", "PAY"));
|
||||
assertThat(chains)
|
||||
.anySatisfy(chain -> assertThat(chain.getPathBindings()).containsEntry("event", "SHIP"));
|
||||
|
||||
Transition pay = transition("NEW", "PAID", "PAY");
|
||||
Transition ship = transition("PAID", "SHIPPED", "SHIP");
|
||||
Transition cancel = transition("NEW", "CANCELLED", "CANCEL");
|
||||
|
||||
AnalysisResult result = AnalysisResult.builder()
|
||||
.name("com.example.config.OrderStateMachineConfig")
|
||||
.eventTypeFqn("com.example.DomainEvent")
|
||||
.stateTypeFqn("com.example.DomainState")
|
||||
.transitions(List.of(pay, ship, cancel))
|
||||
.metadata(CodebaseMetadata.builder().callChains(chains).build())
|
||||
.build();
|
||||
new TriggerCanonicalizationEnricher().enrich(result, context, null);
|
||||
new TransitionLinkerEnricher().enrich(result, context, null);
|
||||
|
||||
List<CallChain> linked = result.getMetadata().getCallChains();
|
||||
assertThat(linked)
|
||||
.filteredOn(c -> c.getLinkResolution() == LinkResolution.RESOLVED)
|
||||
.isNotEmpty();
|
||||
assertThat(linked)
|
||||
.filteredOn(c -> c.getLinkResolution() == LinkResolution.RESOLVED)
|
||||
.anySatisfy(c -> assertThat(c.getMatchedTransitions())
|
||||
.anyMatch(mt -> mt.getEvent() != null && mt.getEvent().contains("PAY")));
|
||||
assertThat(linked)
|
||||
.filteredOn(c -> c.getLinkResolution() == LinkResolution.RESOLVED)
|
||||
.anySatisfy(c -> assertThat(c.getMatchedTransitions())
|
||||
.anyMatch(mt -> mt.getEvent() != null && mt.getEvent().contains("SHIP")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void expandableEventPathWithFieldStashedCarrierStillResolvesViaPostHocSplit(@TempDir Path tempDir)
|
||||
throws Exception {
|
||||
// Expander cannot alias PathVariable → valueOf when the string is stashed in a field first.
|
||||
// P2 gated split must still produce one RESOLVED chain per trusted poly constant.
|
||||
writeFieldStashFixture(tempDir);
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.setResolveBindings(true);
|
||||
context.scan(tempDir);
|
||||
|
||||
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
|
||||
EntryPoint entry = EntryPoint.builder()
|
||||
.type(EntryPoint.Type.REST)
|
||||
.name("POST /api/machine/transition/{event}")
|
||||
.className("com.example.MachineController")
|
||||
.methodName("transition")
|
||||
.parameters(List.of(EntryPoint.Parameter.builder()
|
||||
.name("event")
|
||||
.type("String")
|
||||
.annotations(List.of("PathVariable"))
|
||||
.build()))
|
||||
.build();
|
||||
TriggerPoint trigger = TriggerPoint.builder()
|
||||
.className("com.example.StateMachine")
|
||||
.methodName("sendEvent")
|
||||
.event("ev")
|
||||
.build();
|
||||
|
||||
List<Map<String, String>> variants =
|
||||
EntryPointBindingExpander.expandPathVariableBindings(entry, context, engine.buildCallGraph());
|
||||
assertThat(variants)
|
||||
.as("field stash must defeat expander aliasing so P2 is the recovery path")
|
||||
.isEmpty();
|
||||
|
||||
List<CallChain> chains = engine.findChains(List.of(entry), List.of(trigger));
|
||||
assertThat(chains).isNotEmpty();
|
||||
|
||||
AnalysisResult result = AnalysisResult.builder()
|
||||
.name("com.example.config.OrderStateMachineConfig")
|
||||
.eventTypeFqn("com.example.DomainEvent")
|
||||
.stateTypeFqn("com.example.DomainState")
|
||||
.transitions(List.of(
|
||||
transition("NEW", "PAID", "PAY"),
|
||||
transition("PAID", "SHIPPED", "SHIP"),
|
||||
transition("NEW", "CANCELLED", "CANCEL")))
|
||||
.metadata(CodebaseMetadata.builder().callChains(chains).build())
|
||||
.build();
|
||||
new TriggerCanonicalizationEnricher().enrich(result, context, null);
|
||||
new TransitionLinkerEnricher().enrich(result, context, null);
|
||||
|
||||
List<CallChain> linked = result.getMetadata().getCallChains();
|
||||
assertThat(linked)
|
||||
.filteredOn(c -> c.getLinkResolution() == LinkResolution.RESOLVED)
|
||||
.hasSizeGreaterThanOrEqualTo(2);
|
||||
assertThat(linked)
|
||||
.filteredOn(c -> c.getLinkResolution() == LinkResolution.RESOLVED)
|
||||
.anySatisfy(c -> assertThat(c.getMatchedTransitions())
|
||||
.anyMatch(mt -> mt.getEvent() != null && mt.getEvent().contains("PAY")));
|
||||
assertThat(linked)
|
||||
.filteredOn(c -> c.getLinkResolution() == LinkResolution.RESOLVED)
|
||||
.anySatisfy(c -> assertThat(c.getMatchedTransitions())
|
||||
.anyMatch(mt -> mt.getEvent() != null && mt.getEvent().contains("SHIP")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonExpandableSharedEntryThroughAbstractValueOfStaysAmbiguousWiden(@TempDir Path tempDir)
|
||||
throws Exception {
|
||||
writeFixture(tempDir);
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.setResolveBindings(true);
|
||||
context.scan(tempDir);
|
||||
|
||||
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
|
||||
// No PathVariable / no {event} placeholder — not an expandable binding entry.
|
||||
EntryPoint entry = EntryPoint.builder()
|
||||
.type(EntryPoint.Type.REST)
|
||||
.name("POST /api/machine/transition")
|
||||
.className("com.example.MachineController")
|
||||
.methodName("transition")
|
||||
.parameters(List.of(EntryPoint.Parameter.builder()
|
||||
.name("event")
|
||||
.type("String")
|
||||
.annotations(List.of())
|
||||
.build()))
|
||||
.build();
|
||||
TriggerPoint trigger = TriggerPoint.builder()
|
||||
.className("com.example.StateMachine")
|
||||
.methodName("sendEvent")
|
||||
.event("ev")
|
||||
.build();
|
||||
|
||||
List<Map<String, String>> variants =
|
||||
EntryPointBindingExpander.expandPathVariableBindings(entry, context, engine.buildCallGraph());
|
||||
assertThat(variants).isEmpty();
|
||||
|
||||
List<CallChain> chains = engine.findChains(List.of(entry), List.of(trigger));
|
||||
assertThat(chains).isNotEmpty();
|
||||
|
||||
AnalysisResult result = AnalysisResult.builder()
|
||||
.name("com.example.config.OrderStateMachineConfig")
|
||||
.eventTypeFqn("com.example.DomainEvent")
|
||||
.stateTypeFqn("com.example.DomainState")
|
||||
.transitions(List.of(
|
||||
transition("NEW", "PAID", "PAY"),
|
||||
transition("PAID", "SHIPPED", "SHIP"),
|
||||
transition("NEW", "CANCELLED", "CANCEL")))
|
||||
.metadata(CodebaseMetadata.builder().callChains(chains).build())
|
||||
.build();
|
||||
new TriggerCanonicalizationEnricher().enrich(result, context, null);
|
||||
new TransitionLinkerEnricher().enrich(result, context, null);
|
||||
|
||||
assertThat(result.getMetadata().getCallChains())
|
||||
.isNotEmpty()
|
||||
.allSatisfy(c -> assertThat(c.getLinkResolution())
|
||||
.isEqualTo(LinkResolution.AMBIGUOUS_WIDEN));
|
||||
}
|
||||
|
||||
private static Transition transition(String from, String to, String event) {
|
||||
Transition t = new Transition();
|
||||
t.setSourceStates(List.of(State.of(from, "com.example.DomainState." + from)));
|
||||
t.setTargetStates(List.of(State.of(to, "com.example.DomainState." + to)));
|
||||
t.setEvent(Event.of(event, "com.example.DomainEvent." + event));
|
||||
return t;
|
||||
}
|
||||
|
||||
private static void writeFixture(Path tempDir) throws Exception {
|
||||
Files.writeString(tempDir.resolve("App.java"), """
|
||||
package com.example;
|
||||
|
||||
public class MachineController {
|
||||
private DomainCollaborator collaborator;
|
||||
public void transition(String event) {
|
||||
collaborator.dispatch(event);
|
||||
}
|
||||
}
|
||||
|
||||
class DomainCollaborator extends AbstractDispatcher {
|
||||
DomainCollaborator(StateMachine sm) {
|
||||
super(sm);
|
||||
}
|
||||
}
|
||||
|
||||
abstract class AbstractDispatcher {
|
||||
private final StateMachine sm;
|
||||
AbstractDispatcher(StateMachine sm) {
|
||||
this.sm = sm;
|
||||
}
|
||||
void dispatch(String event) {
|
||||
fire(event);
|
||||
}
|
||||
void fire(String event) {
|
||||
DomainEvent ev = DomainEvent.valueOf(event);
|
||||
sm.sendEvent(ev);
|
||||
}
|
||||
}
|
||||
|
||||
enum DomainEvent { PAY, SHIP, CANCEL }
|
||||
enum DomainState { NEW, PAID, SHIPPED, CANCELLED }
|
||||
class StateMachine {
|
||||
void sendEvent(DomainEvent e) {}
|
||||
}
|
||||
""");
|
||||
writeConfig(tempDir);
|
||||
}
|
||||
|
||||
private static void writeFieldStashFixture(Path tempDir) throws Exception {
|
||||
Files.writeString(tempDir.resolve("App.java"), """
|
||||
package com.example;
|
||||
|
||||
public class MachineController {
|
||||
private DomainCollaborator collaborator;
|
||||
public void transition(String event) {
|
||||
collaborator.stashAndDispatch(event);
|
||||
}
|
||||
}
|
||||
|
||||
class DomainCollaborator extends AbstractDispatcher {
|
||||
DomainCollaborator(StateMachine sm) {
|
||||
super(sm);
|
||||
}
|
||||
}
|
||||
|
||||
abstract class AbstractDispatcher {
|
||||
private final StateMachine sm;
|
||||
private String pendingEvent;
|
||||
AbstractDispatcher(StateMachine sm) {
|
||||
this.sm = sm;
|
||||
}
|
||||
void stashAndDispatch(String event) {
|
||||
this.pendingEvent = event;
|
||||
fireStashed();
|
||||
}
|
||||
void fireStashed() {
|
||||
DomainEvent ev = DomainEvent.valueOf(pendingEvent);
|
||||
sm.sendEvent(ev);
|
||||
}
|
||||
}
|
||||
|
||||
enum DomainEvent { PAY, SHIP, CANCEL }
|
||||
enum DomainState { NEW, PAID, SHIPPED, CANCELLED }
|
||||
class StateMachine {
|
||||
void sendEvent(DomainEvent e) {}
|
||||
}
|
||||
""");
|
||||
writeConfig(tempDir);
|
||||
}
|
||||
|
||||
private static void writeConfig(Path tempDir) throws Exception {
|
||||
Files.createDirectories(tempDir.resolve("com/example/config"));
|
||||
Files.writeString(tempDir.resolve("com/example/config/OrderStateMachineConfig.java"), """
|
||||
package com.example.config;
|
||||
import com.example.DomainEvent;
|
||||
import com.example.DomainState;
|
||||
import org.springframework.statemachine.config.EnumStateMachineConfigurerAdapter;
|
||||
public class OrderStateMachineConfig
|
||||
extends EnumStateMachineConfigurerAdapter<DomainState, DomainEvent> {
|
||||
}
|
||||
""");
|
||||
}
|
||||
}
|
||||
@@ -489,6 +489,164 @@ class EntryPointBindingExpanderTest {
|
||||
.containsExactlyInAnyOrder("PAY", "SHIP");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldExpandEventWhenRenamedAliasIsForwardedAcrossCallEdge(@TempDir Path tempDir) throws Exception {
|
||||
// Intra-method alias (code = event) must be recognized when enqueueing dispatch(code).
|
||||
String source = """
|
||||
package com.example;
|
||||
public class OrderController {
|
||||
OrderGateway gateway;
|
||||
public void transition(String event) {
|
||||
String code = event;
|
||||
gateway.trigger(code);
|
||||
}
|
||||
}
|
||||
class OrderGateway {
|
||||
void trigger(String payload) {
|
||||
OrderEvent.valueOf(payload);
|
||||
}
|
||||
}
|
||||
enum OrderEvent { PAY, SHIP }
|
||||
""";
|
||||
Files.writeString(tempDir.resolve("App.java"), source);
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.setResolveBindings(true);
|
||||
context.scan(tempDir);
|
||||
|
||||
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
|
||||
Map<String, List<click.kamil.springstatemachineexporter.analysis.model.CallEdge>> graph = engine.buildCallGraph();
|
||||
|
||||
EntryPoint entryPoint = EntryPoint.builder()
|
||||
.className("com.example.OrderController")
|
||||
.methodName("transition")
|
||||
.name("POST /api/order/{event}")
|
||||
.parameters(List.of(EntryPoint.Parameter.builder()
|
||||
.name("event")
|
||||
.type("String")
|
||||
.annotations(List.of("PathVariable"))
|
||||
.build()))
|
||||
.build();
|
||||
|
||||
List<Map<String, String>> variants =
|
||||
EntryPointBindingExpander.expandPathVariableBindings(entryPoint, context, graph);
|
||||
|
||||
assertThat(variants).extracting(map -> map.get("event"))
|
||||
.containsExactlyInAnyOrder("PAY", "SHIP");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldExpandEventWhenEntryIsInterfaceAndEdgesLiveOnImplementation(@TempDir Path tempDir)
|
||||
throws Exception {
|
||||
String source = """
|
||||
package com.example;
|
||||
public interface MachineApi {
|
||||
void transition(String event);
|
||||
}
|
||||
public class MachineController implements MachineApi {
|
||||
DomainCollaborator collaborator;
|
||||
public void transition(String event) {
|
||||
collaborator.dispatch(event);
|
||||
}
|
||||
}
|
||||
class DomainCollaborator {
|
||||
void dispatch(String event) {
|
||||
DomainEvent.valueOf(event);
|
||||
}
|
||||
}
|
||||
enum DomainEvent { PAY, SHIP, CANCEL }
|
||||
""";
|
||||
Files.writeString(tempDir.resolve("App.java"), source);
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.setResolveBindings(true);
|
||||
context.scan(tempDir);
|
||||
|
||||
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
|
||||
Map<String, List<click.kamil.springstatemachineexporter.analysis.model.CallEdge>> graph = engine.buildCallGraph();
|
||||
|
||||
// Entry keyed on the interface type (Spring MVC interface controllers / OpenAPI stubs).
|
||||
EntryPoint entryPoint = EntryPoint.builder()
|
||||
.className("com.example.MachineApi")
|
||||
.methodName("transition")
|
||||
.name("POST /api/machine/transition/{event}")
|
||||
.parameters(List.of(EntryPoint.Parameter.builder()
|
||||
.name("event")
|
||||
.type("String")
|
||||
.annotations(List.of("PathVariable"))
|
||||
.build()))
|
||||
.build();
|
||||
|
||||
List<Map<String, String>> variants =
|
||||
EntryPointBindingExpander.expandPathVariableBindings(entryPoint, context, graph);
|
||||
|
||||
assertThat(variants)
|
||||
.as("expander must follow interface entry → impl edges → nested valueOf")
|
||||
.extracting(map -> map.get("event"))
|
||||
.containsExactlyInAnyOrder("PAY", "SHIP", "CANCEL");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNotMergeBindingEdgesFromMultipleInterfaceImplementors(@TempDir Path tempDir)
|
||||
throws Exception {
|
||||
String source = """
|
||||
package com.example;
|
||||
public interface MachineApi {
|
||||
void transition(String event);
|
||||
}
|
||||
public class OrderController implements MachineApi {
|
||||
OrderGateway orderGateway;
|
||||
public void transition(String event) {
|
||||
orderGateway.trigger(event);
|
||||
}
|
||||
}
|
||||
public class DocumentController implements MachineApi {
|
||||
DocumentGateway documentGateway;
|
||||
public void transition(String event) {
|
||||
documentGateway.trigger(event);
|
||||
}
|
||||
}
|
||||
class OrderGateway {
|
||||
void trigger(String event) {
|
||||
OrderEvent.valueOf(event);
|
||||
}
|
||||
}
|
||||
class DocumentGateway {
|
||||
void trigger(String event) {
|
||||
DocumentEvent.valueOf(event);
|
||||
}
|
||||
}
|
||||
enum OrderEvent { PAY, SHIP }
|
||||
enum DocumentEvent { SUBMIT, ARCHIVE }
|
||||
""";
|
||||
Files.writeString(tempDir.resolve("App.java"), source);
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.setResolveBindings(true);
|
||||
context.scan(tempDir);
|
||||
|
||||
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
|
||||
Map<String, List<click.kamil.springstatemachineexporter.analysis.model.CallEdge>> graph = engine.buildCallGraph();
|
||||
|
||||
EntryPoint entryPoint = EntryPoint.builder()
|
||||
.className("com.example.MachineApi")
|
||||
.methodName("transition")
|
||||
.name("POST /api/machine/transition/{event}")
|
||||
.parameters(List.of(EntryPoint.Parameter.builder()
|
||||
.name("event")
|
||||
.type("String")
|
||||
.annotations(List.of("PathVariable"))
|
||||
.build()))
|
||||
.build();
|
||||
|
||||
List<Map<String, String>> variants =
|
||||
EntryPointBindingExpander.expandPathVariableBindings(entryPoint, context, graph);
|
||||
|
||||
assertThat(variants)
|
||||
.as("multi-impl interface edges must not merge unrelated valueOf sites")
|
||||
.isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldExpandEventWhenParameterIsNormalizedWithinMethodBody(@TempDir Path tempDir) throws Exception {
|
||||
String source = """
|
||||
|
||||
Reference in New Issue
Block a user