This commit is contained in:
2026-07-17 14:51:17 +02:00
parent 9a3b0403a7
commit bf8bfd58ed
16 changed files with 1386 additions and 104 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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> {
}
""");
}
}

View File

@@ -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 = """