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.enricher.routing.HeuristicBeanResolutionEngine;
|
||||||
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||||
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
|
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.model.TriggerPoint;
|
||||||
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
|
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
|
||||||
import click.kamil.springstatemachineexporter.analysis.service.CallGraphEngine;
|
import click.kamil.springstatemachineexporter.analysis.service.CallGraphEngine;
|
||||||
@@ -46,14 +47,14 @@ public final class EntryPointScopeResolver {
|
|||||||
for (CallChain chain : chains) {
|
for (CallChain chain : chains) {
|
||||||
EntryPoint entryPoint = chain.getEntryPoint();
|
EntryPoint entryPoint = chain.getEntryPoint();
|
||||||
if (entryPoint != null) {
|
if (entryPoint != null) {
|
||||||
scoped.putIfAbsent(entryPointKey(entryPoint), entryPoint);
|
scoped.putIfAbsent(EntryPointKeys.of(entryPoint), entryPoint);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (allEntryPoints != null) {
|
if (allEntryPoints != null) {
|
||||||
for (EntryPoint entryPoint : allEntryPoints) {
|
for (EntryPoint entryPoint : allEntryPoints) {
|
||||||
if (hasPathVariablePlaceholder(entryPoint)) {
|
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);
|
InjectionPointAnalyzer injectionAnalyzer = new InjectionPointAnalyzer(dependencyResolver);
|
||||||
return new JdtCallGraphEngine(context, injectionAnalyzer);
|
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.CallChain;
|
||||||
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
|
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.LinkResolution;
|
||||||
import click.kamil.springstatemachineexporter.analysis.model.MatchedTransition;
|
import click.kamil.springstatemachineexporter.analysis.model.MatchedTransition;
|
||||||
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||||
@@ -103,14 +104,14 @@ public final class SemanticCallChainDedup {
|
|||||||
if (chain == null) {
|
if (chain == null) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
byEntry.computeIfAbsent(entryPointKey(chain), key -> new ArrayList<>()).add(chain);
|
byEntry.computeIfAbsent(EntryPointKeys.of(chain), key -> new ArrayList<>()).add(chain);
|
||||||
}
|
}
|
||||||
return byEntry;
|
return byEntry;
|
||||||
}
|
}
|
||||||
|
|
||||||
static String semanticKey(CallChain chain) {
|
static String semanticKey(CallChain chain) {
|
||||||
StringBuilder key = new StringBuilder();
|
StringBuilder key = new StringBuilder();
|
||||||
key.append(entryPointKey(chain)).append('|');
|
key.append(EntryPointKeys.of(chain)).append('|');
|
||||||
key.append(triggerKey(chain.getTriggerPoint())).append('|');
|
key.append(triggerKey(chain.getTriggerPoint())).append('|');
|
||||||
key.append(linkResolutionKey(chain.getLinkResolution())).append('|');
|
key.append(linkResolutionKey(chain.getLinkResolution())).append('|');
|
||||||
key.append(matchedTransitionsKey(chain.getMatchedTransitions())).append('|');
|
key.append(matchedTransitionsKey(chain.getMatchedTransitions())).append('|');
|
||||||
@@ -176,7 +177,7 @@ public final class SemanticCallChainDedup {
|
|||||||
if (entryHop.equals(firstHop)) {
|
if (entryHop.equals(firstHop)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return normalizeMethodHopToEntryKey(firstHop)
|
return EntryPointKeys.normalizeMethodHop(firstHop)
|
||||||
.equals(entryPoint.getClassName() + "#" + entryPoint.getMethodName());
|
.equals(entryPoint.getClassName() + "#" + entryPoint.getMethodName());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,38 +199,6 @@ public final class SemanticCallChainDedup {
|
|||||||
return chain.getMethodChain() == null ? Integer.MAX_VALUE : chain.getMethodChain().size();
|
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) {
|
private static String triggerKey(TriggerPoint trigger) {
|
||||||
if (trigger == null) {
|
if (trigger == null) {
|
||||||
return "";
|
return "";
|
||||||
|
|||||||
@@ -63,10 +63,11 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
|
|||||||
context);
|
context);
|
||||||
String effectiveEventTypeFqn = effectiveMachineTypes.eventTypeFqn();
|
String effectiveEventTypeFqn = effectiveMachineTypes.eventTypeFqn();
|
||||||
|
|
||||||
|
List<CallChain> workList = new ArrayList<>();
|
||||||
for (CallChain chain : result.getMetadata().getCallChains()) {
|
for (CallChain chain : result.getMetadata().getCallChains()) {
|
||||||
TriggerPoint tp = chain.getTriggerPoint();
|
TriggerPoint tp = chain.getTriggerPoint();
|
||||||
if (tp == null || tp.getEvent() == null) {
|
if (tp == null || tp.getEvent() == null) {
|
||||||
updatedChains.add(chain);
|
workList.add(chain);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,10 +83,23 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
|
|||||||
tp = TriggerMachineTypeRebinder.rebind(tp, chain.getMethodChain(), context);
|
tp = TriggerMachineTypeRebinder.rebind(tp, chain.getMethodChain(), context);
|
||||||
tp = CallChainLinkPolicy.applyRestExternalPolicy(tp, chain, context);
|
tp = CallChainLinkPolicy.applyRestExternalPolicy(tp, chain, context);
|
||||||
tp = tp.toBuilder()
|
tp = tp.toBuilder()
|
||||||
.eventTypeFqn(concreteOrFallback(tp.getEventTypeFqn(), effectiveEventTypeFqn))
|
.eventTypeFqn(SharedServiceRoutingPolicy.concreteOrFallback(
|
||||||
.stateTypeFqn(concreteOrFallback(tp.getStateTypeFqn(), effectiveMachineTypes.stateTypeFqn()))
|
tp.getEventTypeFqn(), effectiveEventTypeFqn))
|
||||||
|
.stateTypeFqn(SharedServiceRoutingPolicy.concreteOrFallback(
|
||||||
|
tp.getStateTypeFqn(), effectiveMachineTypes.stateTypeFqn()))
|
||||||
.build();
|
.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())) {
|
if (LifecycleTriggerMarkers.isLifecycle(tp.getEvent())) {
|
||||||
updatedChains.add(chain);
|
updatedChains.add(chain);
|
||||||
continue;
|
continue;
|
||||||
@@ -431,21 +445,4 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
|
|||||||
return MachineEnumCanonicalizer.classifyTriggerEvent(trigger.getEvent())
|
return MachineEnumCanonicalizer.classifyTriggerEvent(trigger.getEvent())
|
||||||
== MachineEnumCanonicalizer.TriggerEventKind.CANONICAL_ENUM;
|
== 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;
|
continue;
|
||||||
}
|
}
|
||||||
String eventTypeFqn = triggerPoint.getEventTypeFqn();
|
String eventTypeFqn = triggerPoint.getEventTypeFqn();
|
||||||
if (pe.contains(".") && isStringTypeOrPrimitive(eventTypeFqn)) {
|
if (pe.contains(".") && MachineEnumCanonicalizer.isStringOrPrimitiveEventType(eventTypeFqn)) {
|
||||||
eventTypeFqn = null;
|
eventTypeFqn = null;
|
||||||
}
|
}
|
||||||
if (matchEventNames(pe, smEventRaw, eventTypeFqn)) {
|
if (matchEventNames(pe, smEventRaw, eventTypeFqn)) {
|
||||||
@@ -61,7 +61,7 @@ public class StrictFqnMatchingEngine implements EventMatchingEngine {
|
|||||||
if (!pe.contains(".") && smEventRaw.contains(".")) {
|
if (!pe.contains(".") && smEventRaw.contains(".")) {
|
||||||
String smConst = constantName(smEventRaw);
|
String smConst = constantName(smEventRaw);
|
||||||
if (pe.equals(smConst) && triggerPoint.getEventTypeFqn() != null
|
if (pe.equals(smConst) && triggerPoint.getEventTypeFqn() != null
|
||||||
&& !isStringTypeOrPrimitive(triggerPoint.getEventTypeFqn())) {
|
&& !MachineEnumCanonicalizer.isStringOrPrimitiveEventType(triggerPoint.getEventTypeFqn())) {
|
||||||
String smEnumType = smEventRaw.substring(0, smEventRaw.lastIndexOf('.'));
|
String smEnumType = smEventRaw.substring(0, smEventRaw.lastIndexOf('.'));
|
||||||
return typesMatch(triggerPoint.getEventTypeFqn(), smEnumType);
|
return typesMatch(triggerPoint.getEventTypeFqn(), smEnumType);
|
||||||
}
|
}
|
||||||
@@ -73,7 +73,7 @@ public class StrictFqnMatchingEngine implements EventMatchingEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (polyEvents.isEmpty() && isDynamicVariable(rawTriggerEvent)) {
|
if (polyEvents.isEmpty() && isDynamicVariable(rawTriggerEvent)) {
|
||||||
if (triggerPoint.getEventTypeFqn() != null && !isStringTypeOrPrimitive(triggerPoint.getEventTypeFqn())) {
|
if (triggerPoint.getEventTypeFqn() != null && !MachineEnumCanonicalizer.isStringOrPrimitiveEventType(triggerPoint.getEventTypeFqn())) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Getter/method-call expressions (e.g. richEvent.getId(), getType()) without resolved
|
// 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("()")) {
|
if (rawTriggerEvent.contains(".") || rawTriggerEvent.endsWith("()")) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (triggerPoint.getEventTypeFqn() != null && isStringTypeOrPrimitive(triggerPoint.getEventTypeFqn())) {
|
if (triggerPoint.getEventTypeFqn() != null && MachineEnumCanonicalizer.isStringOrPrimitiveEventType(triggerPoint.getEventTypeFqn())) {
|
||||||
return !smEventRaw.contains(".");
|
return !smEventRaw.contains(".");
|
||||||
}
|
}
|
||||||
return !smEventRaw.contains(".");
|
return !smEventRaw.contains(".");
|
||||||
@@ -100,22 +100,22 @@ public class StrictFqnMatchingEngine implements EventMatchingEngine {
|
|||||||
if (!tConst.equals(smConst)) return false;
|
if (!tConst.equals(smConst)) return false;
|
||||||
|
|
||||||
// String-typed triggers may match configured string transition events by constant name.
|
// 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('.'));
|
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.
|
// 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('.'));
|
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
|
// 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).
|
// itself is a string/primitive type (not a real enum like OrderEvent.PAY ↔ PAY).
|
||||||
if (eventTypeFqn == null && !smEvent.contains(".") && triggerEvent.contains(".")) {
|
if (eventTypeFqn == null && !smEvent.contains(".") && triggerEvent.contains(".")) {
|
||||||
String triggerEnumType = triggerEvent.substring(0, triggerEvent.lastIndexOf('.'));
|
String triggerEnumType = triggerEvent.substring(0, triggerEvent.lastIndexOf('.'));
|
||||||
return isStringTypeOrPrimitive(triggerEnumType);
|
return MachineEnumCanonicalizer.isStringOrPrimitiveEventType(triggerEnumType);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (smEvent.contains(".")) {
|
if (smEvent.contains(".")) {
|
||||||
@@ -154,12 +154,6 @@ public class StrictFqnMatchingEngine implements EventMatchingEngine {
|
|||||||
return event.contains(".") ? event.substring(event.lastIndexOf('.') + 1) : event;
|
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) {
|
private boolean isDynamicVariable(String eventStr) {
|
||||||
if (eventStr == null || eventStr.isEmpty()) return false;
|
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.enricher.matching.StrictFqnMatchingEngine;
|
||||||
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||||
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
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.analysis.resolver.StateMachineTypeResolver;
|
||||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||||
import click.kamil.springstatemachineexporter.model.Transition;
|
import click.kamil.springstatemachineexporter.model.Transition;
|
||||||
@@ -278,11 +279,7 @@ public class HeuristicBeanResolutionEngine implements BeanResolutionEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private boolean isStringType(String typeFqn) {
|
private boolean isStringType(String typeFqn) {
|
||||||
if (typeFqn == null) {
|
return MachineEnumCanonicalizer.isJavaStringType(typeFqn);
|
||||||
return false;
|
|
||||||
}
|
|
||||||
String erased = eraseGenerics(typeFqn);
|
|
||||||
return "java.lang.String".equals(erased) || "String".equals(erased);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private Boolean resolveNamedBeanTarget(String beanName, String currentMachineName, CodebaseContext context) {
|
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))) {
|
if (erased.length() == 1 && Character.isUpperCase(erased.charAt(0))) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return !"java.lang.Object".equals(erased) && !"Object".equals(erased)
|
return !"java.io.Serializable".equals(erased) && !"Serializable".equals(erased)
|
||||||
&& !"java.io.Serializable".equals(erased) && !"Serializable".equals(erased)
|
&& !isNonConcreteMachineErasure(erased);
|
||||||
&& !"java.lang.String".equals(erased) && !"String".equals(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");
|
|| 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) {
|
private static boolean isEnumConstantCandidate(String eventStr) {
|
||||||
if (eventStr == null) {
|
if (eventStr == null) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -221,11 +221,7 @@ public final class EntryPointBindingExpander {
|
|||||||
if (containsBooleanTernaryCondition(methodFqn, paramName, context)) {
|
if (containsBooleanTernaryCondition(methodFqn, paramName, context)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
List<CallEdge> edges = callGraph.get(methodFqn);
|
for (CallEdge edge : edgesForMethod(methodFqn, callGraph, context)) {
|
||||||
if (edges == null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
for (CallEdge edge : edges) {
|
|
||||||
if (edge.getTargetMethod() != null) {
|
if (edge.getTargetMethod() != null) {
|
||||||
queue.add(edge.getTargetMethod());
|
queue.add(edge.getTargetMethod());
|
||||||
}
|
}
|
||||||
@@ -418,6 +414,93 @@ public final class EntryPointBindingExpander {
|
|||||||
return List.of();
|
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) {
|
private static List<String> bindingNamesForParameter(MethodDeclaration method, String paramName) {
|
||||||
if (paramName == null || paramName.isBlank()) {
|
if (paramName == null || paramName.isBlank()) {
|
||||||
return List.of();
|
return List.of();
|
||||||
@@ -492,10 +575,12 @@ public final class EntryPointBindingExpander {
|
|||||||
Map<String, List<CallEdge>> callGraph,
|
Map<String, List<CallEdge>> callGraph,
|
||||||
Map<String, String> partialBindings,
|
Map<String, String> partialBindings,
|
||||||
ArrayDeque<ParameterAliasState> queue) {
|
ArrayDeque<ParameterAliasState> queue) {
|
||||||
List<CallEdge> edges = callGraph.get(state.methodFqn());
|
List<CallEdge> edges = edgesForMethod(state.methodFqn(), callGraph, context);
|
||||||
if (edges == null) {
|
if (edges.isEmpty()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
MethodDeclaration callerMethod = findMethodDeclaration(state.methodFqn(), context);
|
||||||
|
List<String> forwardedNames = bindingNamesForParameter(callerMethod, state.paramName());
|
||||||
for (CallEdge edge : edges) {
|
for (CallEdge edge : edges) {
|
||||||
if (edge.getTargetMethod() == null
|
if (edge.getTargetMethod() == null
|
||||||
|| !isEdgeCompatibleWithBindings(edge, partialBindings, state.methodFqn(), context)) {
|
|| !isEdgeCompatibleWithBindings(edge, partialBindings, state.methodFqn(), context)) {
|
||||||
@@ -511,7 +596,7 @@ public final class EntryPointBindingExpander {
|
|||||||
}
|
}
|
||||||
int limit = Math.min(args.size(), targetParamNames.size());
|
int limit = Math.min(args.size(), targetParamNames.size());
|
||||||
for (int i = 0; i < limit; i++) {
|
for (int i = 0; i < limit; i++) {
|
||||||
if (!argumentForwardsParameter(args.get(i), state.paramName())) {
|
if (!argumentForwardsAnyParameter(args.get(i), forwardedNames)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
MethodDeclaration targetMethod = findMethodDeclaration(edge.getTargetMethod(), context);
|
MethodDeclaration targetMethod = findMethodDeclaration(edge.getTargetMethod(), context);
|
||||||
@@ -541,6 +626,18 @@ public final class EntryPointBindingExpander {
|
|||||||
return BooleanConstraintEvaluator.isCompatibleWithBindings(constraint, partialBindings);
|
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) {
|
private static boolean argumentForwardsParameter(String argument, String paramName) {
|
||||||
if (argument == null || paramName == null) {
|
if (argument == null || paramName == null) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package click.kamil.springstatemachineexporter.analysis.service;
|
package click.kamil.springstatemachineexporter.analysis.service;
|
||||||
|
|
||||||
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
|
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
|
||||||
|
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
@@ -232,10 +233,6 @@ public final class EventCarrierPolicy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static boolean isStringType(String type) {
|
private static boolean isStringType(String type) {
|
||||||
if (type == null) {
|
return MachineEnumCanonicalizer.isJavaStringType(type);
|
||||||
return false;
|
|
||||||
}
|
|
||||||
String simple = type.contains(".") ? type.substring(type.lastIndexOf('.') + 1) : type;
|
|
||||||
return "String".equals(simple);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import click.kamil.springstatemachineexporter.analysis.model.LibraryHint;
|
|||||||
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||||
import click.kamil.springstatemachineexporter.analysis.pipeline.ReactiveExpressionSupport;
|
import click.kamil.springstatemachineexporter.analysis.pipeline.ReactiveExpressionSupport;
|
||||||
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
|
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
|
||||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.eclipse.jdt.core.dom.*;
|
import org.eclipse.jdt.core.dom.*;
|
||||||
@@ -836,9 +837,7 @@ public class GenericEventDetector {
|
|||||||
if (type == null) {
|
if (type == null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
String raw = type.toString();
|
return MachineEnumCanonicalizer.isJavaStringType(type.toString());
|
||||||
String simple = raw.contains(".") ? raw.substring(raw.lastIndexOf('.') + 1) : raw;
|
|
||||||
return "String".equals(simple);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean hasPayloadAnnotation(SingleVariableDeclaration param) {
|
private static boolean hasPayloadAnnotation(SingleVariableDeclaration param) {
|
||||||
|
|||||||
@@ -407,7 +407,52 @@ class CallChainPolyNarrowerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@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 pay = transition("NEW", "PAID", "com.example.OrderEvent.PAY");
|
||||||
Transition ship = transition("PAID", "SHIPPED", "com.example.OrderEvent.SHIP");
|
Transition ship = transition("PAID", "SHIPPED", "com.example.OrderEvent.SHIP");
|
||||||
|
|
||||||
@@ -444,6 +489,79 @@ class CallChainPolyNarrowerTest {
|
|||||||
LinkResolution.UNRESOLVED_EXTERNAL);
|
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) {
|
private static Transition transition(String source, String target, String eventFqn) {
|
||||||
Transition transition = new Transition();
|
Transition transition = new Transition();
|
||||||
transition.setSourceStates(List.of(State.of(source, "com.example.OrderState." + source)));
|
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");
|
.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
|
@Test
|
||||||
void shouldExpandEventWhenParameterIsNormalizedWithinMethodBody(@TempDir Path tempDir) throws Exception {
|
void shouldExpandEventWhenParameterIsNormalizedWithinMethodBody(@TempDir Path tempDir) throws Exception {
|
||||||
String source = """
|
String source = """
|
||||||
|
|||||||
Reference in New Issue
Block a user