From bf8bfd58ed62953ba6bb9ac48cf266ac1184a44f Mon Sep 17 00:00:00 2001 From: Kamil Patryk Kozakowski Date: Fri, 17 Jul 2026 14:51:17 +0200 Subject: [PATCH] D --- .../enricher/EntryPointScopeResolver.java | 12 +- .../enricher/ExpandableEntryPolySplitter.java | 309 +++++++++++++++++ .../enricher/SemanticCallChainDedup.java | 39 +-- .../enricher/TransitionLinkerEnricher.java | 37 +- .../matching/StrictFqnMatchingEngine.java | 24 +- .../HeuristicBeanResolutionEngine.java | 7 +- .../routing/SharedServiceRoutingPolicy.java | 26 +- .../analysis/model/EntryPointKeys.java | 59 ++++ .../resolver/MachineEnumCanonicalizer.java | 28 ++ .../service/EntryPointBindingExpander.java | 113 +++++- .../analysis/service/EventCarrierPolicy.java | 7 +- .../service/GenericEventDetector.java | 5 +- .../enricher/CallChainPolyNarrowerTest.java | 120 ++++++- .../ExpandableEntryPolySplitterTest.java | 224 ++++++++++++ ...pDispatcherExpandableEventBindingTest.java | 322 ++++++++++++++++++ .../EntryPointBindingExpanderTest.java | 158 +++++++++ 16 files changed, 1386 insertions(+), 104 deletions(-) create mode 100644 state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/ExpandableEntryPolySplitter.java create mode 100644 state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/model/EntryPointKeys.java create mode 100644 state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/ExpandableEntryPolySplitterTest.java create mode 100644 state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/DeepDispatcherExpandableEventBindingTest.java diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/EntryPointScopeResolver.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/EntryPointScopeResolver.java index 6d7ecdc..d688bc0 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/EntryPointScopeResolver.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/EntryPointScopeResolver.java @@ -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(); - } } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/ExpandableEntryPolySplitter.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/ExpandableEntryPolySplitter.java new file mode 100644 index 0000000..18daa79 --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/ExpandableEntryPolySplitter.java @@ -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 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 concrete = CallChainLinkPolicy.concretePolymorphicCandidates( + preparedTrigger.getPolymorphicEvents(), + eventTypeFqn(preparedTrigger, machineEventTypeFqn), + context); + if (concrete.size() <= 1) { + return List.of(withPrepared); + } + String carrierParam = resolveCarrierParamName(chain.getEntryPoint()); + List split = new ArrayList<>(concrete.size()); + for (String constantFqn : concrete) { + String literal = constantLiteral(constantFqn); + Map 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 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 pathBindings) { + if (entryPoint == null) { + return false; + } + for (String name : machineDiscriminatorNames(entryPoint)) { + if (!isBound(pathBindings, name)) { + return true; + } + } + return false; + } + + private static List machineDiscriminatorNames(EntryPoint entryPoint) { + List 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 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 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 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; + } +} diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/SemanticCallChainDedup.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/SemanticCallChainDedup.java index 5b4ac88..1e112c4 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/SemanticCallChainDedup.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/SemanticCallChainDedup.java @@ -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 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 ""; diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/TransitionLinkerEnricher.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/TransitionLinkerEnricher.java index 3783eac..75f5fd3 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/TransitionLinkerEnricher.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/TransitionLinkerEnricher.java @@ -63,10 +63,11 @@ public class TransitionLinkerEnricher implements AnalysisEnricher { context); String effectiveEventTypeFqn = effectiveMachineTypes.eventTypeFqn(); + List 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; - } - } \ No newline at end of file diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/matching/StrictFqnMatchingEngine.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/matching/StrictFqnMatchingEngine.java index 9a46e70..fc0f92e 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/matching/StrictFqnMatchingEngine.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/matching/StrictFqnMatchingEngine.java @@ -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; diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/routing/HeuristicBeanResolutionEngine.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/routing/HeuristicBeanResolutionEngine.java index e75e083..6b340f7 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/routing/HeuristicBeanResolutionEngine.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/routing/HeuristicBeanResolutionEngine.java @@ -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) { diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/routing/SharedServiceRoutingPolicy.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/routing/SharedServiceRoutingPolicy.java index afbf66a..a709281 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/routing/SharedServiceRoutingPolicy.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/routing/SharedServiceRoutingPolicy.java @@ -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); } } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/model/EntryPointKeys.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/model/EntryPointKeys.java new file mode 100644 index 0000000..40b9488 --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/model/EntryPointKeys.java @@ -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); + } +} diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizer.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizer.java index c9eed38..715da10 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizer.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizer.java @@ -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 not 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; diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/EntryPointBindingExpander.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/EntryPointBindingExpander.java index 27f447d..eec9666 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/EntryPointBindingExpander.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/EntryPointBindingExpander.java @@ -221,11 +221,7 @@ public final class EntryPointBindingExpander { if (containsBooleanTernaryCondition(methodFqn, paramName, context)) { return true; } - List 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 edgesForMethod( + String methodFqn, + Map> callGraph, + CodebaseContext context) { + if (methodFqn == null || callGraph == null) { + return List.of(); + } + List 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 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 inherited = callGraph.get(superMethod); + if (inherited != null && !inherited.isEmpty()) { + return inherited; + } + typeDeclaration = context.getTypeDeclaration(superclass); + } + List 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 edgesFromInterfaceImplementations( + String typeFqn, + String methodName, + Map> 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 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 soleImplEdges = null; + for (String impl : implementations) { + if (impl == null || impl.isBlank() || impl.equals(typeFqn)) { + continue; + } + List 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 bindingNamesForParameter(MethodDeclaration method, String paramName) { if (paramName == null || paramName.isBlank()) { return List.of(); @@ -492,10 +575,12 @@ public final class EntryPointBindingExpander { Map> callGraph, Map partialBindings, ArrayDeque queue) { - List edges = callGraph.get(state.methodFqn()); - if (edges == null) { + List edges = edgesForMethod(state.methodFqn(), callGraph, context); + if (edges.isEmpty()) { return; } + MethodDeclaration callerMethod = findMethodDeclaration(state.methodFqn(), context); + List 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 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; diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/EventCarrierPolicy.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/EventCarrierPolicy.java index 119d10f..d41d2d7 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/EventCarrierPolicy.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/EventCarrierPolicy.java @@ -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); } } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/GenericEventDetector.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/GenericEventDetector.java index 035bb96..c891e7d 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/GenericEventDetector.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/GenericEventDetector.java @@ -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) { diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/CallChainPolyNarrowerTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/CallChainPolyNarrowerTest.java index 8664610..613f30c 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/CallChainPolyNarrowerTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/CallChainPolyNarrowerTest.java @@ -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 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 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))); diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/ExpandableEntryPolySplitterTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/ExpandableEntryPolySplitterTest.java new file mode 100644 index 0000000..671cf33 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/ExpandableEntryPolySplitterTest.java @@ -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 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 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 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 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 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 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; + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/DeepDispatcherExpandableEventBindingTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/DeepDispatcherExpandableEventBindingTest.java new file mode 100644 index 0000000..6091091 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/DeepDispatcherExpandableEventBindingTest.java @@ -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> 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 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 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> variants = + EntryPointBindingExpander.expandPathVariableBindings(entry, context, engine.buildCallGraph()); + assertThat(variants) + .as("field stash must defeat expander aliasing so P2 is the recovery path") + .isEmpty(); + + List 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 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> variants = + EntryPointBindingExpander.expandPathVariableBindings(entry, context, engine.buildCallGraph()); + assertThat(variants).isEmpty(); + + List 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 { + } + """); + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/EntryPointBindingExpanderTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/EntryPointBindingExpanderTest.java index a69166e..5239d57 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/EntryPointBindingExpanderTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/EntryPointBindingExpanderTest.java @@ -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> 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> 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> 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> 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> 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> 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 = """