2 Commits

Author SHA1 Message Date
6ca8e64750 Restore machine-scoped enum linking when global simple names are ambiguous.
Call-graph ambiguous metadata must not blank matchedTransitions; trust import-style polymorphic widen when the machine event type is known.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 19:34:08 +02:00
5b0778301a Harden cross-module map lookup and add sibling-module regression tests.
Resolve static map field declaring types via import-aware lookup when JDT binding uses the wrong package, and cover qualified inherited maps, static factory receivers, and RequestParam expansion across modules.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 19:20:18 +02:00
16 changed files with 577 additions and 37 deletions

View File

@@ -49,7 +49,8 @@ public final class CallChainLinkPolicy {
&& !isTrustedEnumPolymorphicWiden(trigger, machineEventTypeFqn, context); && !isTrustedEnumPolymorphicWiden(trigger, machineEventTypeFqn, context);
} }
return MachineEnumCanonicalizer.isDynamicTriggerExpression(event) return MachineEnumCanonicalizer.isDynamicTriggerExpression(event)
&& event != null; && event != null
&& !isTrustedEnumPolymorphicWiden(trigger, machineEventTypeFqn, context);
} }
/** /**
@@ -87,9 +88,7 @@ public final class CallChainLinkPolicy {
if (pe == null || pe.startsWith("<SYMBOLIC:") || pe.startsWith("ENUM_SET:") || !pe.contains(".")) { if (pe == null || pe.startsWith("<SYMBOLIC:") || pe.startsWith("ENUM_SET:") || !pe.contains(".")) {
return false; return false;
} }
String enumType = pe.substring(0, pe.lastIndexOf('.')); if (!MachineEnumCanonicalizer.polymorphicEventMatchesMachineEventType(pe, eventTypeFqn, context)) {
if (!enumType.contains(".")
|| !MachineEnumCanonicalizer.enumTypesMatch(eventTypeFqn, enumType, context)) {
return false; return false;
} }
} }
@@ -121,7 +120,6 @@ public final class CallChainLinkPolicy {
return LinkResolution.UNRESOLVED_EXTERNAL; return LinkResolution.UNRESOLVED_EXTERNAL;
} }
if (ambiguousSource if (ambiguousSource
|| (trigger != null && trigger.isAmbiguous())
|| shouldFailClosedOnAmbiguousCallGraphWiden(trigger, machineEventTypeFqn, context)) { || shouldFailClosedOnAmbiguousCallGraphWiden(trigger, machineEventTypeFqn, context)) {
return LinkResolution.AMBIGUOUS_WIDEN; return LinkResolution.AMBIGUOUS_WIDEN;
} }

View File

@@ -5,7 +5,9 @@ import click.kamil.springstatemachineexporter.analysis.enricher.routing.Heuristi
import click.kamil.springstatemachineexporter.analysis.model.CallChain; import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint; import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.model.Event;
import click.kamil.springstatemachineexporter.model.Transition; import click.kamil.springstatemachineexporter.model.Transition;
import java.util.ArrayList; import java.util.ArrayList;
@@ -57,14 +59,48 @@ public final class MachineScopeFilter {
return chains == null ? List.of() : chains; return chains == null ? List.of() : chains;
} }
List<CallChain> filtered = new ArrayList<>(); List<CallChain> filtered = new ArrayList<>();
String machineEventTypeFqn = resolveMachineEventTypeFqn(machineName, context, machineTransitions);
for (CallChain chain : chains) { for (CallChain chain : chains) {
if (ROUTING.hasProvenMachineAffinity(chain, machineName, context, machineTransitions)) { if (ROUTING.hasProvenMachineAffinity(chain, machineName, context, machineTransitions)) {
filtered.add(chain); filtered.add(chain);
continue;
}
TriggerPoint trigger = chain.getTriggerPoint();
if (trigger != null
&& machineEventTypeFqn != null
&& CallChainLinkPolicy.isTrustedEnumPolymorphicWiden(trigger, machineEventTypeFqn, context)) {
filtered.add(chain);
} }
} }
return filtered; return filtered;
} }
private static String resolveMachineEventTypeFqn(
String machineName,
CodebaseContext context,
List<Transition> machineTransitions) {
if (context != null && machineName != null) {
String[] types = StateMachineTypeResolver.resolve(machineName, context);
if (types != null && types.length > 1 && types[1] != null && !types[1].isBlank()) {
return types[1];
}
}
if (machineTransitions == null) {
return null;
}
for (Transition transition : machineTransitions) {
Event event = transition.getEvent();
if (event == null) {
continue;
}
String fullIdentifier = event.fullIdentifier() != null ? event.fullIdentifier() : event.rawName();
if (fullIdentifier != null && fullIdentifier.contains(".")) {
return fullIdentifier.substring(0, fullIdentifier.lastIndexOf('.'));
}
}
return null;
}
public static List<EntryPoint> filterEntryPointsForMachine( public static List<EntryPoint> filterEntryPointsForMachine(
List<EntryPoint> entryPoints, String machineName, CodebaseContext context) { List<EntryPoint> entryPoints, String machineName, CodebaseContext context) {
if (entryPoints == null || machineName == null) { if (entryPoints == null || machineName == null) {

View File

@@ -63,6 +63,16 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
tp, machineTypes, context, stateMachineTransitions, true); tp, machineTypes, context, stateMachineTransitions, true);
tp = MachineEnumCanonicalizer.canonicalizeTriggerLabelsForLinking(tp, machineTypes, context); tp = MachineEnumCanonicalizer.canonicalizeTriggerLabelsForLinking(tp, machineTypes, context);
tp = CallChainLinkPolicy.applyRestExternalPolicy(tp, chain, context); tp = CallChainLinkPolicy.applyRestExternalPolicy(tp, chain, context);
if (machineTypes != null) {
tp = tp.toBuilder()
.eventTypeFqn(tp.getEventTypeFqn() != null
? tp.getEventTypeFqn()
: machineTypes.eventTypeFqn())
.stateTypeFqn(tp.getStateTypeFqn() != null
? tp.getStateTypeFqn()
: machineTypes.stateTypeFqn())
.build();
}
if (LifecycleTriggerMarkers.isLifecycle(tp.getEvent())) { if (LifecycleTriggerMarkers.isLifecycle(tp.getEvent())) {
updatedChains.add(chain); updatedChains.add(chain);
@@ -89,7 +99,9 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
Map<String, String> canonicalSourceBySimplified = new LinkedHashMap<>(); Map<String, String> canonicalSourceBySimplified = new LinkedHashMap<>();
for (Transition t : stateMachineTransitions) { for (Transition t : stateMachineTransitions) {
if (t.getEvent() != null && matchingEngine.matches(t.getEvent(), tp) if (t.getEvent() != null && matchingEngine.matches(t.getEvent(), tp)
&& isRoutedToCorrectMachine(chain, result.getName(), context, stateMachineTransitions) && isRoutedToCorrectMachine(
chain, result.getName(), context, stateMachineTransitions, tp,
machineTypes != null ? machineTypes.eventTypeFqn() : null)
&& isConstraintCompatible(tp.getConstraint(), result.getName())) { && isConstraintCompatible(tp.getConstraint(), result.getName())) {
String smEventForLink = canonicalEvent(t.getEvent()); String smEventForLink = canonicalEvent(t.getEvent());
for (State smSourceState : t.getSourceStates()) { for (State smSourceState : t.getSourceStates()) {
@@ -131,7 +143,9 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
.targetState(smSourceForLink) .targetState(smSourceForLink)
.event(smEventForLink) .event(smEventForLink)
.build(); .build();
if (isRoutedToCorrectMachine(chain, result.getName(), context, stateMachineTransitions) if (isRoutedToCorrectMachine(
chain, result.getName(), context, stateMachineTransitions, tp,
machineTypes != null ? machineTypes.eventTypeFqn() : null)
&& isConstraintCompatible(tp.getConstraint(), result.getName())) { && isConstraintCompatible(tp.getConstraint(), result.getName())) {
matched.add(mt); matched.add(mt);
} }
@@ -143,7 +157,9 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
.targetState(targetForLink) .targetState(targetForLink)
.event(smEventForLink) .event(smEventForLink)
.build(); .build();
if (isRoutedToCorrectMachine(chain, result.getName(), context, stateMachineTransitions) if (isRoutedToCorrectMachine(
chain, result.getName(), context, stateMachineTransitions, tp,
machineTypes != null ? machineTypes.eventTypeFqn() : null)
&& isConstraintCompatible(tp.getConstraint(), result.getName())) { && isConstraintCompatible(tp.getConstraint(), result.getName())) {
matched.add(mt); matched.add(mt);
} }
@@ -201,10 +217,26 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
private boolean isRoutedToCorrectMachine( private boolean isRoutedToCorrectMachine(
CallChain chain, String currentMachineName, CodebaseContext context, List<Transition> machineTransitions) { CallChain chain,
String currentMachineName,
CodebaseContext context,
List<Transition> machineTransitions,
TriggerPoint triggerPoint,
String machineEventTypeFqn) {
if (triggerPoint != null
&& machineEventTypeFqn != null
&& CallChainLinkPolicy.isTrustedEnumPolymorphicWiden(triggerPoint, machineEventTypeFqn, context)) {
return true;
}
return routingEngine.hasProvenMachineAffinity(chain, currentMachineName, context, machineTransitions); return routingEngine.hasProvenMachineAffinity(chain, currentMachineName, context, machineTransitions);
} }
private boolean isRoutedToCorrectMachine(
CallChain chain, String currentMachineName, CodebaseContext context, List<Transition> machineTransitions) {
return isRoutedToCorrectMachine(
chain, currentMachineName, context, machineTransitions, chain.getTriggerPoint(), null);
}
private boolean isConstraintCompatible(String constraint, String machineName) { private boolean isConstraintCompatible(String constraint, String machineName) {
if (constraint == null || machineName == null) { if (constraint == null || machineName == null) {
return true; return true;

View File

@@ -131,7 +131,15 @@ public class StrictFqnMatchingEngine implements EventMatchingEngine {
} }
private boolean typesMatch(String type1, String type2) { private boolean typesMatch(String type1, String type2) {
return MachineEnumCanonicalizer.enumTypesMatch(type1, type2, context); if (MachineEnumCanonicalizer.enumTypesMatch(type1, type2, context)) {
return true;
}
if (type1 != null && type2 != null) {
String machineFqn = type1.contains(".") ? type1 : type2;
String candidate = type1.contains(".") ? type2 : type1;
return MachineEnumCanonicalizer.enumTypesMatchForMachine(machineFqn, candidate, context);
}
return false;
} }
private String constantName(String event) { private String constantName(String event) {

View File

@@ -126,10 +126,15 @@ public class HeuristicBeanResolutionEngine implements BeanResolutionEngine {
if (mismatched) { if (mismatched) {
return false; return false;
} }
// Provable generic types but no machine match — fail closed (no package-name fallback). // Fail closed only when both trigger and machine generic types were resolved but
// could not be reconciled. When machine config AST does not expose type args (stub
// config classes in tests, generated configs), fall through to single-SM / package routing.
boolean machineTypesKnown = machineEventFqn != null || machineStateFqn != null;
if (machineTypesKnown) {
return false; return false;
} }
} }
}
if (!isSingleStateMachine(context) && isAmbiguousSharedGenericTrigger(chain.getTriggerPoint())) { if (!isSingleStateMachine(context) && isAmbiguousSharedGenericTrigger(chain.getTriggerPoint())) {
Boolean injectionMatch = SpringInjectionRouting.matchesMachineConfig( Boolean injectionMatch = SpringInjectionRouting.matchesMachineConfig(

View File

@@ -842,18 +842,17 @@ public final class MachineEnumCanonicalizer {
String constant = constantName(stripped); String constant = constantName(stripped);
String typePart = enumTypeFromRef(stripped); String typePart = enumTypeFromRef(stripped);
if (typePart != null && context != null && context.isAmbiguousSimpleName(typePart)) { if (typePart != null && !typePart.contains(".") && importStyleEnumTypeMatches(typePart, enumTypeFqn)
return stripped; && constant.matches("[A-Z_][A-Z0-9_]*")) {
return enumTypeFqn + "." + constant;
} }
if (typePart != null && enumTypesMatch(enumTypeFqn, typePart, context)) { if (typePart != null && enumTypesMatch(enumTypeFqn, typePart, context)) {
return enumTypeFqn + "." + constant; return enumTypeFqn + "." + constant;
} }
if (typePart != null && !typePart.contains(".") && !enumTypesMatch(enumTypeFqn, typePart, context) if (typePart != null && context != null && context.isAmbiguousSimpleName(typePart)) {
&& importStyleEnumTypeMatches(typePart, enumTypeFqn) return stripped;
&& constant.matches("[A-Z_][A-Z0-9_]*")) {
return enumTypeFqn + "." + constant;
} }
if (typePart == null && Character.isUpperCase(stripped.charAt(0)) && !stripped.contains(".")) { if (typePart == null && Character.isUpperCase(stripped.charAt(0)) && !stripped.contains(".")) {
@@ -917,6 +916,47 @@ public final class MachineEnumCanonicalizer {
return enumTypesMatch(type1, type2); return enumTypesMatch(type1, type2);
} }
/**
* Whether a concrete polymorphic enum constant belongs to the configured machine event type.
* Uses machine context to accept import-style labels even when the enum simple name is
* ambiguous across the scanned codebase.
*/
public static boolean polymorphicEventMatchesMachineEventType(
String polymorphicEvent,
String machineEventTypeFqn,
CodebaseContext context) {
if (polymorphicEvent == null || machineEventTypeFqn == null || !polymorphicEvent.contains(".")) {
return false;
}
if (polymorphicEvent.startsWith("<SYMBOLIC:") || polymorphicEvent.startsWith("ENUM_SET:")) {
return false;
}
String enumType = polymorphicEvent.substring(0, polymorphicEvent.lastIndexOf('.'));
if (machineEventTypeFqn.equals(enumType)) {
return true;
}
if (importStyleEnumTypeMatches(enumType, machineEventTypeFqn)) {
return true;
}
return enumType.contains(".") && enumTypesMatch(machineEventTypeFqn, enumType, context);
}
public static boolean enumTypesMatchForMachine(
String machineEnumFqn,
String candidateType,
CodebaseContext context) {
if (machineEnumFqn == null || candidateType == null) {
return false;
}
if (machineEnumFqn.equals(candidateType)) {
return true;
}
if (importStyleEnumTypeMatches(candidateType, machineEnumFqn)) {
return true;
}
return candidateType.contains(".") && enumTypesMatch(machineEnumFqn, candidateType, context);
}
private static String simpleName(String fqn) { private static String simpleName(String fqn) {
return fqn.contains(".") ? fqn.substring(fqn.lastIndexOf('.') + 1) : fqn; return fqn.contains(".") ? fqn.substring(fqn.lastIndexOf('.') + 1) : fqn;
} }

View File

@@ -672,9 +672,7 @@ public final class EntryPointBindingExpander {
org.eclipse.jdt.core.dom.IVariableBinding fieldBinding = fieldAccess.resolveFieldBinding(); org.eclipse.jdt.core.dom.IVariableBinding fieldBinding = fieldAccess.resolveFieldBinding();
if (fieldBinding != null && fieldBinding.isField()) { if (fieldBinding != null && fieldBinding.isField()) {
org.eclipse.jdt.core.dom.ITypeBinding declaringClass = fieldBinding.getDeclaringClass(); org.eclipse.jdt.core.dom.ITypeBinding declaringClass = fieldBinding.getDeclaringClass();
if (declaringClass != null) { declaringType = resolveFieldDeclaringType(declaringClass, compilationUnit, context);
declaringType = context.getTypeDeclaration(declaringClass.getQualifiedName());
}
} }
if (declaringType == null) { if (declaringType == null) {
Expression qualifier = fieldAccess.getExpression(); Expression qualifier = fieldAccess.getExpression();
@@ -693,9 +691,7 @@ public final class EntryPointBindingExpander {
if (binding instanceof org.eclipse.jdt.core.dom.IVariableBinding variableBinding if (binding instanceof org.eclipse.jdt.core.dom.IVariableBinding variableBinding
&& variableBinding.isField()) { && variableBinding.isField()) {
org.eclipse.jdt.core.dom.ITypeBinding declaringClass = variableBinding.getDeclaringClass(); org.eclipse.jdt.core.dom.ITypeBinding declaringClass = variableBinding.getDeclaringClass();
if (declaringClass != null) { declaringType = resolveFieldDeclaringType(declaringClass, compilationUnit, context);
declaringType = context.getTypeDeclaration(declaringClass.getQualifiedName());
}
} }
if (declaringType == null && compilationUnit != null) { if (declaringType == null && compilationUnit != null) {
declaringType = context.resolveStaticImport(fieldName, compilationUnit); declaringType = context.resolveStaticImport(fieldName, compilationUnit);
@@ -724,6 +720,27 @@ public final class EntryPointBindingExpander {
return extractStaticFieldMapInitializer(declaringType, fieldName, context); return extractStaticFieldMapInitializer(declaringType, fieldName, context);
} }
private static TypeDeclaration resolveFieldDeclaringType(
org.eclipse.jdt.core.dom.ITypeBinding declaringClass,
CompilationUnit compilationUnit,
CodebaseContext context) {
if (declaringClass == null) {
return null;
}
org.eclipse.jdt.core.dom.ITypeBinding erased = declaringClass.getErasure();
TypeDeclaration typeDeclaration = context.getTypeDeclaration(erased.getQualifiedName());
if (typeDeclaration != null) {
return typeDeclaration;
}
if (compilationUnit != null) {
typeDeclaration = context.getTypeDeclaration(erased.getName(), compilationUnit);
if (typeDeclaration != null) {
return typeDeclaration;
}
}
return context.getTypeDeclaration(erased.getName());
}
private static boolean isStaticMapFieldReceiver(Expression receiver) { private static boolean isStaticMapFieldReceiver(Expression receiver) {
if (receiver instanceof FieldAccess fieldAccess) { if (receiver instanceof FieldAccess fieldAccess) {
org.eclipse.jdt.core.dom.IVariableBinding fieldBinding = fieldAccess.resolveFieldBinding(); org.eclipse.jdt.core.dom.IVariableBinding fieldBinding = fieldAccess.resolveFieldBinding();

View File

@@ -102,4 +102,38 @@ class CallChainLinkPolicyTest {
assertThat(CallChainLinkPolicy.isTrustedEnumPolymorphicWiden(trigger)).isFalse(); assertThat(CallChainLinkPolicy.isTrustedEnumPolymorphicWiden(trigger)).isFalse();
assertThat(CallChainLinkPolicy.shouldFailClosedOnAmbiguousCallGraphWiden(trigger)).isTrue(); assertThat(CallChainLinkPolicy.shouldFailClosedOnAmbiguousCallGraphWiden(trigger)).isTrue();
} }
@Test
void shouldTrustImportStylePolymorphicWidenWhenMachineEventTypeIsKnown() {
TriggerPoint trigger = TriggerPoint.builder()
.ambiguous(true)
.event("OrderEvent.valueOf(eventStr)")
.polymorphicEvents(List.of("OrderEvent.PAY", "OrderEvent.SHIP"))
.build();
assertThat(CallChainLinkPolicy.isTrustedEnumPolymorphicWiden(
trigger, "com.example.order.OrderEvent")).isTrue();
assertThat(CallChainLinkPolicy.shouldFailClosedOnAmbiguousCallGraphWiden(
trigger, "com.example.order.OrderEvent")).isFalse();
}
@Test
void shouldResolveWhenTrustedWidenHasMatchesDespiteAmbiguousCallGraphFlag() {
TriggerPoint trigger = TriggerPoint.builder()
.ambiguous(true)
.event("OrderEvent.valueOf(eventStr)")
.polymorphicEvents(List.of("OrderEvent.PAY", "OrderEvent.SHIP"))
.build();
var matched = List.of(
click.kamil.springstatemachineexporter.analysis.model.MatchedTransition.builder()
.event("com.example.order.OrderEvent.PAY")
.build());
assertThat(CallChainLinkPolicy.resolveLinkResolution(
trigger,
matched,
false,
"com.example.order.OrderEvent")).isEqualTo(LinkResolution.RESOLVED);
}
} }

View File

@@ -0,0 +1,117 @@
package click.kamil.springstatemachineexporter.analysis.enricher;
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.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 static org.assertj.core.api.Assertions.assertThat;
/**
* Regression for enterprise multi-module scans: a globally ambiguous enum simple name must not
* block machine-scoped {@code valueOf} widens from linking when polymorphic events use import-style
* labels and the machine event type is known.
*/
class TrustedEnumWidenLinkingRegressionTest {
@Test
void shouldLinkTrustedImportStyleWidenWhenEnumSimpleNameIsGloballyAmbiguous(@TempDir Path tempDir) throws Exception {
Files.createDirectories(tempDir.resolve("a"));
Files.createDirectories(tempDir.resolve("b"));
Files.writeString(tempDir.resolve("a/OrderEvent.java"),
"package a; public enum OrderEvent { PAY }");
Files.writeString(tempDir.resolve("b/OrderEvent.java"),
"package b; public enum OrderEvent { CANCEL }");
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
assertThat(context.isAmbiguousSimpleName("OrderEvent")).isTrue();
Transition pay = new Transition();
pay.setSourceStates(List.of(State.of("NEW", "com.example.order.OrderState.NEW")));
pay.setTargetStates(List.of(State.of("PAID", "com.example.order.OrderState.PAID")));
pay.setEvent(Event.of("PAY", "com.example.order.OrderEvent.PAY"));
Transition ship = new Transition();
ship.setSourceStates(List.of(State.of("PAID", "com.example.order.OrderState.PAID")));
ship.setTargetStates(List.of(State.of("SHIPPED", "com.example.order.OrderState.SHIPPED")));
ship.setEvent(Event.of("SHIP", "com.example.order.OrderEvent.SHIP"));
CallChain chain = CallChain.builder()
.triggerPoint(TriggerPoint.builder()
.event("OrderEvent.valueOf(eventStr)")
.ambiguous(true)
.external(false)
.polymorphicEvents(List.of("OrderEvent.PAY", "OrderEvent.SHIP"))
.build())
.build();
AnalysisResult result = AnalysisResult.builder()
.name("com.example.config.OrderStateMachineConfiguration")
.eventTypeFqn("com.example.order.OrderEvent")
.stateTypeFqn("com.example.order.OrderState")
.transitions(List.of(pay, ship))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
.build();
new TransitionLinkerEnricher().enrich(result, null, null);
CallChain linked = result.getMetadata().getCallChains().get(0);
assertThat(linked.getLinkResolution()).isEqualTo(LinkResolution.RESOLVED);
assertThat(linked.getMatchedTransitions()).hasSizeGreaterThanOrEqualTo(1);
assertThat(linked.getTriggerPoint().getPolymorphicEvents())
.containsExactly("com.example.order.OrderEvent.PAY", "com.example.order.OrderEvent.SHIP");
}
@Test
void shouldKeepTrustedWidenCallChainWhenEnumSimpleNameIsGloballyAmbiguous(@TempDir Path tempDir) throws Exception {
Files.createDirectories(tempDir.resolve("a"));
Files.createDirectories(tempDir.resolve("b"));
Files.writeString(tempDir.resolve("a/OrderEvent.java"),
"package a; public enum OrderEvent { PAY }");
Files.writeString(tempDir.resolve("b/OrderEvent.java"),
"package b; public enum OrderEvent { CANCEL }");
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
assertThat(context.isAmbiguousSimpleName("OrderEvent")).isTrue();
Transition pay = new Transition();
pay.setSourceStates(List.of(State.of("NEW", "com.example.order.OrderState.NEW")));
pay.setTargetStates(List.of(State.of("PAID", "com.example.order.OrderState.PAID")));
pay.setEvent(Event.of("PAY", "com.example.order.OrderEvent.PAY"));
Transition ship = new Transition();
ship.setSourceStates(List.of(State.of("PAID", "com.example.order.OrderState.PAID")));
ship.setTargetStates(List.of(State.of("SHIPPED", "com.example.order.OrderState.SHIPPED")));
ship.setEvent(Event.of("SHIP", "com.example.order.OrderEvent.SHIP"));
CallChain chain = CallChain.builder()
.triggerPoint(TriggerPoint.builder()
.event("OrderEvent.valueOf(eventStr)")
.ambiguous(true)
.external(false)
.polymorphicEvents(List.of("OrderEvent.PAY", "OrderEvent.SHIP"))
.build())
.build();
List<CallChain> scoped = MachineScopeFilter.filterCallChainsForMachine(
List.of(chain),
"com.example.config.OrderStateMachineConfiguration",
context,
List.of(pay, ship));
assertThat(scoped).hasSize(1);
}
}

View File

@@ -340,7 +340,7 @@ class StrictFqnMatchingEngineTest {
} }
@Test @Test
void shouldNotMatchImportStyleEnumWhenSimpleNameIsAmbiguous(@TempDir java.nio.file.Path tempDir) throws Exception { void shouldMatchImportStyleEnumToMachineFqnWhenSimpleNamesAlign(@TempDir java.nio.file.Path tempDir) throws Exception {
java.nio.file.Files.createDirectories(tempDir.resolve("a")); java.nio.file.Files.createDirectories(tempDir.resolve("a"));
java.nio.file.Files.createDirectories(tempDir.resolve("b")); java.nio.file.Files.createDirectories(tempDir.resolve("b"));
java.nio.file.Files.writeString(tempDir.resolve("a/OrderEvent.java"), java.nio.file.Files.writeString(tempDir.resolve("a/OrderEvent.java"),
@@ -359,6 +359,6 @@ class StrictFqnMatchingEngineTest {
.polymorphicEvents(List.of("OrderEvent.PAY")) .polymorphicEvents(List.of("OrderEvent.PAY"))
.build(); .build();
assertThat(engine.matches(smEvent, triggerPoint)).isFalse(); assertThat(engine.matches(smEvent, triggerPoint)).isTrue();
} }
} }

View File

@@ -32,7 +32,7 @@ class MachineEnumCanonicalizerCrossPackageTest {
} }
@Test @Test
void shouldNotRewriteImportStyleWhenSimpleNameIsAmbiguous(@TempDir Path tempDir) throws Exception { void shouldCanonicalizeImportStyleWhenMachineEnumTypeIsKnown(@TempDir Path tempDir) throws Exception {
Files.createDirectories(tempDir.resolve("a")); Files.createDirectories(tempDir.resolve("a"));
Files.createDirectories(tempDir.resolve("b")); Files.createDirectories(tempDir.resolve("b"));
Files.writeString(tempDir.resolve("a/OrderEvent.java"), Files.writeString(tempDir.resolve("a/OrderEvent.java"),
@@ -44,7 +44,7 @@ class MachineEnumCanonicalizerCrossPackageTest {
context.scan(tempDir); context.scan(tempDir);
assertThat(MachineEnumCanonicalizer.canonicalizeLabel("OrderEvent.PAY", "a.OrderEvent", context)) assertThat(MachineEnumCanonicalizer.canonicalizeLabel("OrderEvent.PAY", "a.OrderEvent", context))
.isEqualTo("OrderEvent.PAY"); .isEqualTo("a.OrderEvent.PAY");
} }
@Test @Test
@@ -112,7 +112,7 @@ class MachineEnumCanonicalizerCrossPackageTest {
} }
@Test @Test
void shouldNotCanonicalizeAmbiguousImportStyleTransitionEvents(@TempDir Path tempDir) throws Exception { void shouldCanonicalizeAmbiguousImportStyleTransitionEventsWhenMachineTypeKnown(@TempDir Path tempDir) throws Exception {
Files.createDirectories(tempDir.resolve("a")); Files.createDirectories(tempDir.resolve("a"));
Files.createDirectories(tempDir.resolve("b")); Files.createDirectories(tempDir.resolve("b"));
Files.writeString(tempDir.resolve("a/OrderEvent.java"), Files.writeString(tempDir.resolve("a/OrderEvent.java"),
@@ -138,7 +138,7 @@ class MachineEnumCanonicalizerCrossPackageTest {
"a.OrderState", "a.OrderEvent"); "a.OrderState", "a.OrderEvent");
MachineEnumCanonicalizer.canonicalizeTransitions(List.of(transition), types, context); MachineEnumCanonicalizer.canonicalizeTransitions(List.of(transition), types, context);
assertThat(transition.getEvent().fullIdentifier()).isEqualTo("OrderEvent.PAY"); assertThat(transition.getEvent().fullIdentifier()).isEqualTo("a.OrderEvent.PAY");
assertThat(transition.getSourceStates().get(0).fullIdentifier()).isEqualTo("a.OrderState.NEW"); assertThat(transition.getSourceStates().get(0).fullIdentifier()).isEqualTo("a.OrderState.NEW");
} }
} }

View File

@@ -227,7 +227,7 @@ class AmbiguousSimpleEnumLinkingTest {
} }
@Test @Test
void shouldNotRewriteAmbiguousImportStylePolyDuringTriggerCanonicalization(@TempDir Path tempDir) throws Exception { void shouldCanonicalizeImportStylePolyWhenMachineEventTypeIsKnown(@TempDir Path tempDir) throws Exception {
Files.createDirectories(tempDir.resolve("a")); Files.createDirectories(tempDir.resolve("a"));
Files.createDirectories(tempDir.resolve("b")); Files.createDirectories(tempDir.resolve("b"));
Files.writeString(tempDir.resolve("a/OrderEvent.java"), Files.writeString(tempDir.resolve("a/OrderEvent.java"),
@@ -257,7 +257,7 @@ class AmbiguousSimpleEnumLinkingTest {
TriggerPoint canonical = result.getMetadata().getCallChains().get(0).getTriggerPoint(); TriggerPoint canonical = result.getMetadata().getCallChains().get(0).getTriggerPoint();
assertThat(canonical.getPolymorphicEvents()) assertThat(canonical.getPolymorphicEvents())
.containsExactly("OrderEvent.PAY", "OrderEvent.SHIP"); .containsExactly("a.OrderEvent.PAY", "a.OrderEvent.SHIP");
} }
} }

View File

@@ -124,7 +124,7 @@ class AnalysisResultFinalizerTest {
} }
@Test @Test
void shouldPreserveAmbiguousImportStyleMatchedTransitions(@TempDir Path tempDir) throws Exception { void shouldCanonicalizeAmbiguousImportStyleMatchedTransitions(@TempDir Path tempDir) throws Exception {
Files.createDirectories(tempDir.resolve("a")); Files.createDirectories(tempDir.resolve("a"));
Files.createDirectories(tempDir.resolve("b")); Files.createDirectories(tempDir.resolve("b"));
Files.createDirectories(tempDir.resolve("com/example/config")); Files.createDirectories(tempDir.resolve("com/example/config"));
@@ -165,7 +165,7 @@ class AnalysisResultFinalizerTest {
"a.OrderState", "a.OrderEvent")); "a.OrderState", "a.OrderEvent"));
assertThat(result.getMetadata().getCallChains().get(0).getMatchedTransitions().get(0).getEvent()) assertThat(result.getMetadata().getCallChains().get(0).getMatchedTransitions().get(0).getEvent())
.isEqualTo("OrderEvent.PAY"); .isEqualTo("a.OrderEvent.PAY");
} }
private static void writeSampleConfig(Path tempDir) throws Exception { private static void writeSampleConfig(Path tempDir) throws Exception {

View File

@@ -883,4 +883,227 @@ class MultiModulePathBindingExpanderTest {
assertThat(variants).extracting(map -> map.get("commandKey")) assertThat(variants).extracting(map -> map.get("commandKey"))
.containsExactlyInAnyOrder("order.pay", "order.ship"); .containsExactlyInAnyOrder("order.pay", "order.ship");
} }
@Test
void shouldExpandCommandKeyFromQualifiedInheritedStaticMapInSiblingModule(@TempDir Path tempDir) throws Exception {
Path root = tempDir.resolve("order-system");
Files.createDirectories(root);
Files.writeString(root.resolve("settings.gradle"), "include 'api-module', 'core-module'");
Path apiModule = root.resolve("api-module");
Files.createDirectories(apiModule.resolve("src/main/java/com/example/routes"));
Files.writeString(apiModule.resolve("build.gradle"), "");
Files.writeString(apiModule.resolve("src/main/java/com/example/routes/BaseRoutes.java"), """
package com.example.routes;
import java.util.Map;
public abstract class BaseRoutes {
protected static final Map<String, String> ROUTES = Map.of(
"order.pay", "OrderEvent.PAY",
"order.ship", "OrderEvent.SHIP");
}
""");
Path coreModule = root.resolve("core-module");
Files.createDirectories(coreModule.resolve("src/main/java/com/example"));
Files.writeString(coreModule.resolve("build.gradle"),
"dependencies { implementation project(':api-module') }");
Files.writeString(coreModule.resolve("src/main/java/com/example/GenericCommandController.java"), """
package com.example;
import com.example.routes.BaseRoutes;
public class GenericCommandController {
OrderGateway gateway;
public void execute(String commandKey) {
gateway.trigger(commandKey);
}
}
class OrderGateway extends BaseRoutes {
void trigger(String commandKey) {
OrderEvent event = OrderEvent.valueOf(BaseRoutes.ROUTES.get(commandKey));
StateMachine sm = new StateMachine();
sm.sendEvent(event);
}
}
enum OrderEvent { PAY, SHIP }
class StateMachine { void sendEvent(OrderEvent event) {} }
""");
SiblingDependencyResolver siblingResolver = new SiblingDependencyResolver();
ProjectModuleGraph graph = siblingResolver.analyzeProject(coreModule);
Set<Path> scanPaths = graph.resolveScanPaths(coreModule, false);
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.scan(scanPaths, Collections.emptySet());
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
Map<String, List<click.kamil.springstatemachineexporter.analysis.model.CallEdge>> callGraph =
engine.buildCallGraph();
EntryPoint entryPoint = EntryPoint.builder()
.className("com.example.GenericCommandController")
.methodName("execute")
.name("POST /api/commands/{commandKey}")
.parameters(List.of(EntryPoint.Parameter.builder()
.name("commandKey")
.type("String")
.annotations(List.of("PathVariable"))
.build()))
.build();
List<Map<String, String>> variants =
EntryPointBindingExpander.expandPathVariableBindings(entryPoint, context, callGraph);
assertThat(variants).extracting(map -> map.get("commandKey"))
.containsExactlyInAnyOrder("order.pay", "order.ship");
}
@Test
void shouldExpandCommandKeyFromStaticMethodMapReceiverInSiblingModule(@TempDir Path tempDir) throws Exception {
Path root = tempDir.resolve("order-system");
Files.createDirectories(root);
Files.writeString(root.resolve("settings.gradle"), "include 'api-module', 'core-module'");
Path apiModule = root.resolve("api-module");
Files.createDirectories(apiModule.resolve("src/main/java/com/example/routes"));
Files.writeString(apiModule.resolve("build.gradle"), "");
Files.writeString(apiModule.resolve("src/main/java/com/example/routes/CommandRoutes.java"), """
package com.example.routes;
import java.util.Map;
public class CommandRoutes {
public static Map<String, String> routes() {
return Map.of(
"order.pay", "OrderEvent.PAY",
"order.ship", "OrderEvent.SHIP");
}
}
""");
Path coreModule = root.resolve("core-module");
Files.createDirectories(coreModule.resolve("src/main/java/com/example"));
Files.writeString(coreModule.resolve("build.gradle"),
"dependencies { implementation project(':api-module') }");
Files.writeString(coreModule.resolve("src/main/java/com/example/GenericCommandController.java"), """
package com.example;
import com.example.routes.CommandRoutes;
public class GenericCommandController {
OrderGateway gateway;
public void execute(String commandKey) {
gateway.trigger(commandKey);
}
}
class OrderGateway {
void trigger(String commandKey) {
OrderEvent event = OrderEvent.valueOf(CommandRoutes.routes().get(commandKey));
StateMachine sm = new StateMachine();
sm.sendEvent(event);
}
}
enum OrderEvent { PAY, SHIP }
class StateMachine { void sendEvent(OrderEvent event) {} }
""");
SiblingDependencyResolver siblingResolver = new SiblingDependencyResolver();
ProjectModuleGraph graph = siblingResolver.analyzeProject(coreModule);
Set<Path> scanPaths = graph.resolveScanPaths(coreModule, false);
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.scan(scanPaths, Collections.emptySet());
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
Map<String, List<click.kamil.springstatemachineexporter.analysis.model.CallEdge>> callGraph =
engine.buildCallGraph();
EntryPoint entryPoint = EntryPoint.builder()
.className("com.example.GenericCommandController")
.methodName("execute")
.name("POST /api/commands/{commandKey}")
.parameters(List.of(EntryPoint.Parameter.builder()
.name("commandKey")
.type("String")
.annotations(List.of("PathVariable"))
.build()))
.build();
List<Map<String, String>> variants =
EntryPointBindingExpander.expandPathVariableBindings(entryPoint, context, callGraph);
assertThat(variants).extracting(map -> map.get("commandKey"))
.containsExactlyInAnyOrder("order.pay", "order.ship");
}
@Test
void shouldExpandRequestParamFromStaticMapLookupInSiblingModule(@TempDir Path tempDir) throws Exception {
Path root = tempDir.resolve("order-system");
Files.createDirectories(root);
Files.writeString(root.resolve("settings.gradle"), "include 'api-module', 'core-module'");
Path apiModule = root.resolve("api-module");
Files.createDirectories(apiModule.resolve("src/main/java/com/example/routes"));
Files.writeString(apiModule.resolve("build.gradle"), "");
Files.writeString(apiModule.resolve("src/main/java/com/example/routes/CommandRoutes.java"), """
package com.example.routes;
import java.util.Map;
public class CommandRoutes {
public static final Map<String, String> ROUTES = Map.of(
"order.pay", "OrderEvent.PAY",
"order.ship", "OrderEvent.SHIP");
}
""");
Path coreModule = root.resolve("core-module");
Files.createDirectories(coreModule.resolve("src/main/java/com/example"));
Files.writeString(coreModule.resolve("build.gradle"),
"dependencies { implementation project(':api-module') }");
Files.writeString(coreModule.resolve("src/main/java/com/example/GenericCommandController.java"), """
package com.example;
import com.example.routes.CommandRoutes;
public class GenericCommandController {
OrderGateway gateway;
public void execute(String commandKey) {
gateway.trigger(commandKey);
}
}
class OrderGateway {
void trigger(String commandKey) {
OrderEvent event = OrderEvent.valueOf(CommandRoutes.ROUTES.get(commandKey));
StateMachine sm = new StateMachine();
sm.sendEvent(event);
}
}
enum OrderEvent { PAY, SHIP }
class StateMachine { void sendEvent(OrderEvent event) {} }
""");
SiblingDependencyResolver siblingResolver = new SiblingDependencyResolver();
ProjectModuleGraph graph = siblingResolver.analyzeProject(coreModule);
Set<Path> scanPaths = graph.resolveScanPaths(coreModule, false);
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.scan(scanPaths, Collections.emptySet());
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
Map<String, List<click.kamil.springstatemachineexporter.analysis.model.CallEdge>> callGraph =
engine.buildCallGraph();
EntryPoint entryPoint = EntryPoint.builder()
.className("com.example.GenericCommandController")
.methodName("execute")
.name("POST /api/commands")
.parameters(List.of(EntryPoint.Parameter.builder()
.name("commandKey")
.type("String")
.annotations(List.of("RequestParam"))
.build()))
.build();
List<Map<String, String>> variants =
EntryPointBindingExpander.expandPathVariableBindings(entryPoint, context, callGraph);
assertThat(variants).extracting(map -> map.get("commandKey"))
.containsExactlyInAnyOrder("order.pay", "order.ship");
assertThat(EntryPointBindingExpander.withResolvedPath(entryPoint, variants.get(0)).getName())
.isEqualTo("POST /api/commands?commandKey=order.pay");
}
} }

View File

@@ -291,6 +291,28 @@ class CodebaseContextTest {
assertThat(context.areClassesPolymorphicallyCompatible("a.Dispatcher", "b.Dispatcher")).isFalse(); assertThat(context.areClassesPolymorphicallyCompatible("a.Dispatcher", "b.Dispatcher")).isFalse();
} }
@Test
void getSuperclassFqnShouldPreferImportAwareResolutionWhenBindingMisResolvesPackage() throws IOException {
Files.createDirectories(tempDir.resolve("com/example/routes"));
Files.writeString(tempDir.resolve("com/example/routes/BaseRoutes.java"), """
package com.example.routes;
public abstract class BaseRoutes {}
""");
Files.writeString(tempDir.resolve("com/example/App.java"), """
package com.example;
import com.example.routes.BaseRoutes;
class OrderGateway extends BaseRoutes {}
""");
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.scan(tempDir);
assertThat(context.getTypeDeclaration("com.example.routes.BaseRoutes")).isNotNull();
assertThat(context.getSuperclassFqn(context.getTypeDeclaration("com.example.OrderGateway")))
.isEqualTo("com.example.routes.BaseRoutes");
}
@Test @Test
void shouldFindClassWithFullyQualifiedAnnotation() throws IOException { void shouldFindClassWithFullyQualifiedAnnotation() throws IOException {
String source = """ String source = """

View File

@@ -738,8 +738,16 @@
"ambiguous" : true "ambiguous" : true
}, },
"contextMachineId" : null, "contextMachineId" : null,
"matchedTransitions" : null, "matchedTransitions" : [ {
"linkResolution" : "AMBIGUOUS_WIDEN" "sourceState" : "click.kamil.examples.statemachine.polymorphic.OrderStates.SUBMITTED",
"targetState" : "click.kamil.examples.statemachine.polymorphic.OrderStates.PAID",
"event" : "click.kamil.examples.statemachine.polymorphic.OrderEvents.PAY"
}, {
"sourceState" : "click.kamil.examples.statemachine.polymorphic.OrderStates.PAID",
"targetState" : "click.kamil.examples.statemachine.polymorphic.OrderStates.CANCELED",
"event" : "click.kamil.examples.statemachine.polymorphic.OrderEvents.ABCD"
} ],
"linkResolution" : "RESOLVED"
} ], } ],
"properties" : { "properties" : {
"default" : { "default" : {