Exclude cross-machine call chains from per-machine exports and link string-literal sendEvent triggers.

Shared String/Object generic types no longer prove machine affinity when multiple configs coexist, so Payment exports omit Order/Audit NO_MATCH stubs while @Qualifier-backed chains stay RESOLVED. String literal events now match configured string transition names for provable linking.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-13 19:14:25 +02:00
parent c3c5d66031
commit 024458d638
12 changed files with 318 additions and 1209 deletions

View File

@@ -6,6 +6,7 @@ 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.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.model.Transition;
import java.util.ArrayList;
import java.util.List;
@@ -22,12 +23,20 @@ public final class MachineScopeFilter {
public static List<TriggerPoint> filterTriggersForMachine(
List<TriggerPoint> triggers, String machineName, CodebaseContext context) {
return filterTriggersForMachine(triggers, machineName, context, null);
}
public static List<TriggerPoint> filterTriggersForMachine(
List<TriggerPoint> triggers,
String machineName,
CodebaseContext context,
List<Transition> machineTransitions) {
if (triggers == null || machineName == null) {
return triggers == null ? List.of() : triggers;
}
List<TriggerPoint> filtered = new ArrayList<>();
for (TriggerPoint trigger : triggers) {
if (isTriggerForMachine(trigger, machineName, context)) {
if (isTriggerForMachine(trigger, machineName, context, machineTransitions)) {
filtered.add(trigger);
}
}
@@ -36,12 +45,20 @@ public final class MachineScopeFilter {
public static List<CallChain> filterCallChainsForMachine(
List<CallChain> chains, String machineName, CodebaseContext context) {
return filterCallChainsForMachine(chains, machineName, context, null);
}
public static List<CallChain> filterCallChainsForMachine(
List<CallChain> chains,
String machineName,
CodebaseContext context,
List<Transition> machineTransitions) {
if (chains == null || machineName == null) {
return chains == null ? List.of() : chains;
}
List<CallChain> filtered = new ArrayList<>();
for (CallChain chain : chains) {
if (ROUTING.isRoutedToCorrectMachine(chain, machineName, context)) {
if (ROUTING.hasProvenMachineAffinity(chain, machineName, context, machineTransitions)) {
filtered.add(chain);
}
}
@@ -84,11 +101,12 @@ public final class MachineScopeFilter {
return false;
}
private static boolean isTriggerForMachine(TriggerPoint trigger, String machineName, CodebaseContext context) {
private static boolean isTriggerForMachine(
TriggerPoint trigger, String machineName, CodebaseContext context, List<Transition> machineTransitions) {
if (trigger == null) {
return false;
}
CallChain probe = CallChain.builder().triggerPoint(trigger).methodChain(List.of()).build();
return ROUTING.isRoutedToCorrectMachine(probe, machineName, context);
return ROUTING.hasProvenMachineAffinity(probe, machineName, context, machineTransitions);
}
}

View File

@@ -83,7 +83,7 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
Map<String, String> canonicalSourceBySimplified = new LinkedHashMap<>();
for (Transition t : stateMachineTransitions) {
if (t.getEvent() != null && matchingEngine.matches(t.getEvent(), tp)
&& isRoutedToCorrectMachine(chain, result.getName(), context)
&& isRoutedToCorrectMachine(chain, result.getName(), context, stateMachineTransitions)
&& isConstraintCompatible(tp.getConstraint(), result.getName())) {
String smEventForLink = canonicalEvent(t.getEvent());
for (State smSourceState : t.getSourceStates()) {
@@ -125,7 +125,7 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
.targetState(smSourceForLink)
.event(smEventForLink)
.build();
if (isRoutedToCorrectMachine(chain, result.getName(), context)
if (isRoutedToCorrectMachine(chain, result.getName(), context, stateMachineTransitions)
&& isConstraintCompatible(tp.getConstraint(), result.getName())) {
matched.add(mt);
}
@@ -137,7 +137,7 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
.targetState(targetForLink)
.event(smEventForLink)
.build();
if (isRoutedToCorrectMachine(chain, result.getName(), context)
if (isRoutedToCorrectMachine(chain, result.getName(), context, stateMachineTransitions)
&& isConstraintCompatible(tp.getConstraint(), result.getName())) {
matched.add(mt);
}
@@ -172,14 +172,16 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
}
List<TriggerPoint> scopedTriggers = MachineScopeFilter.filterTriggersForMachine(
result.getMetadata().getTriggers(), result.getName(), context);
result.getMetadata().getTriggers(), result.getName(), context, stateMachineTransitions);
List<CallChain> scopedChains = MachineScopeFilter.filterCallChainsForMachine(
updatedChains, result.getName(), context, stateMachineTransitions);
List<EntryPoint> scopedEntryPoints = EntryPointScopeResolver.scopeFromCallChains(
updatedChains, result.getMetadata().getEntryPoints());
scopedChains, result.getMetadata().getEntryPoints());
CodebaseMetadata updatedMetadata = CodebaseMetadata.builder()
.triggers(scopedTriggers)
.entryPoints(scopedEntryPoints)
.callChains(updatedChains)
.callChains(scopedChains)
.properties(result.getMetadata().getProperties())
.build();
@@ -187,8 +189,9 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
}
private boolean isRoutedToCorrectMachine(CallChain chain, String currentMachineName, CodebaseContext context) {
return routingEngine.isRoutedToCorrectMachine(chain, currentMachineName, context);
private boolean isRoutedToCorrectMachine(
CallChain chain, String currentMachineName, CodebaseContext context, List<Transition> machineTransitions) {
return routingEngine.hasProvenMachineAffinity(chain, currentMachineName, context, machineTransitions);
}
private boolean isConstraintCompatible(String constraint, String machineName) {

View File

@@ -88,9 +88,10 @@ public class StrictFqnMatchingEngine implements EventMatchingEngine {
if (!tConst.equals(smConst)) return false;
// Prevent matching string/primitive triggers to enum transitions
// String-typed triggers may match configured string transition events by constant name.
if (isStringTypeOrPrimitive(eventTypeFqn) && smEvent.contains(".") && !triggerEvent.contains(".")) {
return false;
String smEnumType = smEvent.substring(0, smEvent.lastIndexOf('.'));
return isStringTypeOrPrimitive(smEnumType);
}
// Prevent matching enum triggers to string transitions

View File

@@ -2,8 +2,19 @@ package click.kamil.springstatemachineexporter.analysis.enricher.routing;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.model.Transition;
import java.util.List;
public interface BeanResolutionEngine {
boolean isRoutedToCorrectMachine(CallChain chain, String currentMachineName, CodebaseContext context);
default boolean hasProvenMachineAffinity(
CallChain chain,
String currentMachineName,
CodebaseContext context,
List<Transition> machineTransitions) {
return isRoutedToCorrectMachine(chain, currentMachineName, context);
}
}

View File

@@ -1,23 +1,76 @@
package click.kamil.springstatemachineexporter.analysis.enricher.routing;
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.StateMachineTypeResolver;
import click.kamil.springstatemachineexporter.ast.common.AstUtils;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.model.Transition;
import org.eclipse.jdt.core.dom.Annotation;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
public class HeuristicBeanResolutionEngine implements BeanResolutionEngine {
private final StrictFqnMatchingEngine matchingEngine = new StrictFqnMatchingEngine();
private enum DistinctTypeAffinity {
FOR_MACHINE,
AGAINST_MACHINE,
UNKNOWN
}
@Override
public boolean hasProvenMachineAffinity(
CallChain chain,
String currentMachineName,
CodebaseContext context,
List<Transition> machineTransitions) {
if (chain == null) {
return false;
}
if (currentMachineName == null) {
return isRoutedToCorrectMachine(chain, currentMachineName, context);
}
String explicitTarget = resolveExplicitTarget(chain);
if (explicitTarget != null && !explicitTarget.isEmpty() && context != null) {
Boolean namedTarget = resolveNamedBeanTarget(explicitTarget, currentMachineName, context);
if (namedTarget != null) {
return namedTarget;
}
}
if (chain.getTriggerPoint() != null && context != null) {
DistinctTypeAffinity typeAffinity =
resolveDistinctTypeAffinity(chain.getTriggerPoint(), currentMachineName, context);
if (typeAffinity == DistinctTypeAffinity.FOR_MACHINE) {
return true;
}
if (typeAffinity == DistinctTypeAffinity.AGAINST_MACHINE) {
return false;
}
}
if (context == null || isSingleStateMachine(context)) {
return isRoutedToCorrectMachine(chain, currentMachineName, context);
}
TriggerPoint trigger = chain.getTriggerPoint();
if (trigger != null && matchesConfiguredTransitionEvent(trigger, machineTransitions)) {
return true;
}
return false;
}
@Override
public boolean isRoutedToCorrectMachine(CallChain chain, String currentMachineName, CodebaseContext context) {
String explicitTarget = chain.getContextMachineId();
if (explicitTarget == null && chain.getTriggerPoint() != null) {
explicitTarget = chain.getTriggerPoint().getStateMachineId();
}
String explicitTarget = resolveExplicitTarget(chain);
if (explicitTarget != null && !explicitTarget.isEmpty() && context != null && currentMachineName != null) {
Boolean namedTarget = resolveNamedBeanTarget(explicitTarget, currentMachineName, context);
if (namedTarget != null) {
@@ -59,7 +112,11 @@ public class HeuristicBeanResolutionEngine implements BeanResolutionEngine {
}
}
if (matched) {
return true;
if (!isAmbiguousSharedGenericMatch(
triggerEventFqn, triggerStateFqn, machineStateFqn, machineEventFqn)
|| isSingleStateMachine(context)) {
return true;
}
}
if (mismatched) {
return false;
@@ -144,6 +201,105 @@ public class HeuristicBeanResolutionEngine implements BeanResolutionEngine {
return context == null || isSingleStateMachine(context);
}
private static String resolveExplicitTarget(CallChain chain) {
if (chain == null) {
return null;
}
String explicitTarget = chain.getContextMachineId();
if (explicitTarget == null && chain.getTriggerPoint() != null) {
explicitTarget = chain.getTriggerPoint().getStateMachineId();
}
return explicitTarget;
}
private DistinctTypeAffinity resolveDistinctTypeAffinity(
TriggerPoint trigger, String machineName, CodebaseContext context) {
String triggerEventFqn = trigger.getEventTypeFqn();
String triggerStateFqn = trigger.getStateTypeFqn();
if (triggerEventFqn == null && triggerStateFqn == null) {
return DistinctTypeAffinity.UNKNOWN;
}
String[] machineTypes = StateMachineTypeResolver.resolve(machineName, context);
String machineStateFqn = machineTypes[0];
String machineEventFqn = machineTypes[1];
boolean matched = false;
boolean mismatched = false;
if (triggerEventFqn != null && machineEventFqn != null) {
if (typesEquivalent(triggerEventFqn, machineEventFqn)
&& !isAmbiguousSharedGenericType(triggerEventFqn)) {
matched = true;
} else if (isDistinctTypeMismatched(triggerEventFqn, machineEventFqn)) {
mismatched = true;
}
}
if (triggerStateFqn != null && machineStateFqn != null) {
if (typesEquivalent(triggerStateFqn, machineStateFqn)
&& !isAmbiguousSharedGenericType(triggerStateFqn)) {
matched = true;
} else if (isDistinctTypeMismatched(triggerStateFqn, machineStateFqn)) {
mismatched = true;
}
}
if (matched) {
return DistinctTypeAffinity.FOR_MACHINE;
}
if (mismatched) {
return DistinctTypeAffinity.AGAINST_MACHINE;
}
return DistinctTypeAffinity.UNKNOWN;
}
private boolean matchesConfiguredTransitionEvent(TriggerPoint trigger, List<Transition> machineTransitions) {
if (trigger == null || machineTransitions == null || machineTransitions.isEmpty()) {
return false;
}
for (Transition transition : machineTransitions) {
if (transition.getEvent() != null && matchingEngine.matches(transition.getEvent(), trigger)) {
return true;
}
}
return false;
}
private boolean isAmbiguousSharedGenericMatch(
String triggerEventFqn,
String triggerStateFqn,
String machineStateFqn,
String machineEventFqn) {
boolean eventShared = triggerEventFqn != null
&& machineEventFqn != null
&& typesEquivalent(triggerEventFqn, machineEventFqn)
&& isAmbiguousSharedGenericType(triggerEventFqn);
boolean stateShared = triggerStateFqn != null
&& machineStateFqn != null
&& typesEquivalent(triggerStateFqn, machineStateFqn)
&& isAmbiguousSharedGenericType(triggerStateFqn);
return eventShared || stateShared;
}
private boolean isAmbiguousSharedGenericType(String typeFqn) {
return isErasedOrOpaqueType(typeFqn) || isStringType(typeFqn);
}
private boolean isDistinctTypeMismatched(String type1, String type2) {
if (isAmbiguousSharedGenericType(type1) || isAmbiguousSharedGenericType(type2)) {
return false;
}
return isTypeMismatched(type1, type2);
}
private boolean isStringType(String typeFqn) {
if (typeFqn == null) {
return false;
}
String erased = eraseGenerics(typeFqn);
return "java.lang.String".equals(erased) || "String".equals(erased);
}
private Boolean resolveNamedBeanTarget(String beanName, String currentMachineName, CodebaseContext context) {
String normalized = stripAnnotationQuotes(beanName);
if (normalized == null || normalized.isEmpty()) {