Thread CodebaseContext through canonical form validator

requireCanonical and event matching now use context-aware enum checks so ambiguous import-style identifiers are not falsely rewritten during export validation.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-14 06:28:56 +02:00
parent be1ac91beb
commit 4a8b6ae9d9
2 changed files with 108 additions and 49 deletions

View File

@@ -50,7 +50,7 @@ public final class AnalysisCanonicalFormValidator {
return violations; return violations;
} }
validateFields(result, machineTypes, violations); validateFields(result, machineTypes, violations, context);
return violations; return violations;
} }
@@ -66,7 +66,7 @@ public final class AnalysisCanonicalFormValidator {
return violations; return violations;
} }
validateFields(result, machineTypes, violations); validateFields(result, machineTypes, violations, null);
return violations; return violations;
} }
@@ -89,17 +89,18 @@ public final class AnalysisCanonicalFormValidator {
private static void validateFields( private static void validateFields(
AnalysisResult result, AnalysisResult result,
StateMachineTypeResolver.MachineTypes machineTypes, StateMachineTypeResolver.MachineTypes machineTypes,
List<Violation> violations) { List<Violation> violations,
validateTransitions(result.getTransitions(), machineTypes, violations); CodebaseContext context) {
validateStateCollection(result.getStates(), machineTypes.stateTypeFqn(), "states", violations); validateTransitions(result.getTransitions(), machineTypes, violations, context);
validateStateCollection(result.getStates(), machineTypes.stateTypeFqn(), "states", violations, context);
validateStateRawNameSync(result.getStates(), machineTypes.stateTypeFqn(), "states", violations); validateStateRawNameSync(result.getStates(), machineTypes.stateTypeFqn(), "states", violations);
validateStateLabels(result.getStartStates(), machineTypes.stateTypeFqn(), "startStates", violations); validateStateLabels(result.getStartStates(), machineTypes.stateTypeFqn(), "startStates", violations, context);
validateStateLabels(result.getEndStates(), machineTypes.stateTypeFqn(), "endStates", violations); validateStateLabels(result.getEndStates(), machineTypes.stateTypeFqn(), "endStates", violations, context);
if (result.getMetadata() != null) { if (result.getMetadata() != null) {
validateTriggers(result.getMetadata().getTriggers(), machineTypes, violations); validateTriggers(result.getMetadata().getTriggers(), machineTypes, violations, context);
validateCallChains( validateCallChains(
result.getMetadata().getCallChains(), result.getTransitions(), machineTypes, violations); result.getMetadata().getCallChains(), result.getTransitions(), machineTypes, violations, context);
} }
} }
@@ -114,7 +115,8 @@ public final class AnalysisCanonicalFormValidator {
private static void validateTransitions( private static void validateTransitions(
List<Transition> transitions, List<Transition> transitions,
StateMachineTypeResolver.MachineTypes machineTypes, StateMachineTypeResolver.MachineTypes machineTypes,
List<Violation> violations) { List<Violation> violations,
CodebaseContext context) {
if (transitions == null) { if (transitions == null) {
return; return;
} }
@@ -126,7 +128,8 @@ public final class AnalysisCanonicalFormValidator {
prefix + ".event.fullIdentifier", prefix + ".event.fullIdentifier",
transition.getEvent().fullIdentifier(), transition.getEvent().fullIdentifier(),
machineTypes.eventTypeFqn(), machineTypes.eventTypeFqn(),
violations); violations,
context);
requireRawNameSync( requireRawNameSync(
prefix + ".event.rawName", prefix + ".event.rawName",
transition.getEvent().rawName(), transition.getEvent().rawName(),
@@ -135,9 +138,9 @@ public final class AnalysisCanonicalFormValidator {
violations); violations);
} }
validateStateListWithRawSync( validateStateListWithRawSync(
transition.getSourceStates(), machineTypes.stateTypeFqn(), prefix + ".sourceStates", violations); transition.getSourceStates(), machineTypes.stateTypeFqn(), prefix + ".sourceStates", violations, context);
validateStateListWithRawSync( validateStateListWithRawSync(
transition.getTargetStates(), machineTypes.stateTypeFqn(), prefix + ".targetStates", violations); transition.getTargetStates(), machineTypes.stateTypeFqn(), prefix + ".targetStates", violations, context);
} }
} }
@@ -145,13 +148,14 @@ public final class AnalysisCanonicalFormValidator {
List<State> states, List<State> states,
String stateTypeFqn, String stateTypeFqn,
String pathPrefix, String pathPrefix,
List<Violation> violations) { List<Violation> violations,
CodebaseContext context) {
if (states == null) { if (states == null) {
return; return;
} }
for (int i = 0; i < states.size(); i++) { for (int i = 0; i < states.size(); i++) {
State state = states.get(i); State state = states.get(i);
requireCanonical(pathPrefix + "[" + i + "].fullIdentifier", state.fullIdentifier(), stateTypeFqn, violations); requireCanonical(pathPrefix + "[" + i + "].fullIdentifier", state.fullIdentifier(), stateTypeFqn, violations, context);
requireRawNameSync( requireRawNameSync(
pathPrefix + "[" + i + "].rawName", pathPrefix + "[" + i + "].rawName",
state.rawName(), state.rawName(),
@@ -183,13 +187,14 @@ public final class AnalysisCanonicalFormValidator {
Set<State> states, Set<State> states,
String stateTypeFqn, String stateTypeFqn,
String pathPrefix, String pathPrefix,
List<Violation> violations) { List<Violation> violations,
CodebaseContext context) {
if (states == null) { if (states == null) {
return; return;
} }
int i = 0; int i = 0;
for (State state : states) { for (State state : states) {
requireCanonical(pathPrefix + "[" + i++ + "].fullIdentifier", state.fullIdentifier(), stateTypeFqn, violations); requireCanonical(pathPrefix + "[" + i++ + "].fullIdentifier", state.fullIdentifier(), stateTypeFqn, violations, context);
} }
} }
@@ -197,20 +202,22 @@ public final class AnalysisCanonicalFormValidator {
Set<String> labels, Set<String> labels,
String stateTypeFqn, String stateTypeFqn,
String pathPrefix, String pathPrefix,
List<Violation> violations) { List<Violation> violations,
CodebaseContext context) {
if (labels == null) { if (labels == null) {
return; return;
} }
int i = 0; int i = 0;
for (String label : labels) { for (String label : labels) {
requireCanonical(pathPrefix + "[" + i++ + "]", label, stateTypeFqn, violations); requireCanonical(pathPrefix + "[" + i++ + "]", label, stateTypeFqn, violations, context);
} }
} }
private static void validateTriggers( private static void validateTriggers(
List<TriggerPoint> triggers, List<TriggerPoint> triggers,
StateMachineTypeResolver.MachineTypes machineTypes, StateMachineTypeResolver.MachineTypes machineTypes,
List<Violation> violations) { List<Violation> violations,
CodebaseContext context) {
if (triggers == null) { if (triggers == null) {
return; return;
} }
@@ -220,16 +227,16 @@ public final class AnalysisCanonicalFormValidator {
String eventTypeFqn = preferTypeFqn(trigger.getEventTypeFqn(), machineTypes.eventTypeFqn()); String eventTypeFqn = preferTypeFqn(trigger.getEventTypeFqn(), machineTypes.eventTypeFqn());
String stateTypeFqn = preferTypeFqn(trigger.getStateTypeFqn(), machineTypes.stateTypeFqn()); String stateTypeFqn = preferTypeFqn(trigger.getStateTypeFqn(), machineTypes.stateTypeFqn());
requireCanonical(prefix + ".event", trigger.getEvent(), eventTypeFqn, violations); requireCanonical(prefix + ".event", trigger.getEvent(), eventTypeFqn, violations, context);
validateTriggerEventForm(prefix + ".event", trigger.getEvent(), violations); validateTriggerEventForm(prefix + ".event", trigger.getEvent(), violations);
requireCanonical(prefix + ".sourceState", trigger.getSourceState(), stateTypeFqn, violations); requireCanonical(prefix + ".sourceState", trigger.getSourceState(), stateTypeFqn, violations, context);
validateDynamicIdentifierForm(prefix + ".sourceState", trigger.getSourceState(), violations); validateDynamicIdentifierForm(prefix + ".sourceState", trigger.getSourceState(), violations);
if (trigger.getPolymorphicEvents() != null) { if (trigger.getPolymorphicEvents() != null) {
for (int j = 0; j < trigger.getPolymorphicEvents().size(); j++) { for (int j = 0; j < trigger.getPolymorphicEvents().size(); j++) {
String polyPath = prefix + ".polymorphicEvents[" + j + "]"; String polyPath = prefix + ".polymorphicEvents[" + j + "]";
String polyEvent = trigger.getPolymorphicEvents().get(j); String polyEvent = trigger.getPolymorphicEvents().get(j);
requireCanonical(polyPath, polyEvent, eventTypeFqn, violations); requireCanonical(polyPath, polyEvent, eventTypeFqn, violations, context);
validatePolymorphicEventForm(polyPath, polyEvent, violations); validatePolymorphicEventForm(polyPath, polyEvent, violations);
} }
} }
@@ -240,7 +247,8 @@ public final class AnalysisCanonicalFormValidator {
List<CallChain> callChains, List<CallChain> callChains,
List<Transition> transitions, List<Transition> transitions,
StateMachineTypeResolver.MachineTypes machineTypes, StateMachineTypeResolver.MachineTypes machineTypes,
List<Violation> violations) { List<Violation> violations,
CodebaseContext context) {
if (callChains == null) { if (callChains == null) {
return; return;
} }
@@ -252,30 +260,30 @@ public final class AnalysisCanonicalFormValidator {
String triggerPrefix = prefix + ".triggerPoint"; String triggerPrefix = prefix + ".triggerPoint";
String eventTypeFqn = preferTypeFqn(trigger.getEventTypeFqn(), machineTypes.eventTypeFqn()); String eventTypeFqn = preferTypeFqn(trigger.getEventTypeFqn(), machineTypes.eventTypeFqn());
String stateTypeFqn = preferTypeFqn(trigger.getStateTypeFqn(), machineTypes.stateTypeFqn()); String stateTypeFqn = preferTypeFqn(trigger.getStateTypeFqn(), machineTypes.stateTypeFqn());
requireCanonical(triggerPrefix + ".event", trigger.getEvent(), eventTypeFqn, violations); requireCanonical(triggerPrefix + ".event", trigger.getEvent(), eventTypeFqn, violations, context);
validateTriggerEventForm(triggerPrefix + ".event", trigger.getEvent(), violations); validateTriggerEventForm(triggerPrefix + ".event", trigger.getEvent(), violations);
requireCanonical(triggerPrefix + ".sourceState", trigger.getSourceState(), stateTypeFqn, violations); requireCanonical(triggerPrefix + ".sourceState", trigger.getSourceState(), stateTypeFqn, violations, context);
validateDynamicIdentifierForm(triggerPrefix + ".sourceState", trigger.getSourceState(), violations); validateDynamicIdentifierForm(triggerPrefix + ".sourceState", trigger.getSourceState(), violations);
if (trigger.getPolymorphicEvents() != null) { if (trigger.getPolymorphicEvents() != null) {
for (int j = 0; j < trigger.getPolymorphicEvents().size(); j++) { for (int j = 0; j < trigger.getPolymorphicEvents().size(); j++) {
String polyPath = triggerPrefix + ".polymorphicEvents[" + j + "]"; String polyPath = triggerPrefix + ".polymorphicEvents[" + j + "]";
String polyEvent = trigger.getPolymorphicEvents().get(j); String polyEvent = trigger.getPolymorphicEvents().get(j);
requireCanonical(polyPath, polyEvent, eventTypeFqn, violations); requireCanonical(polyPath, polyEvent, eventTypeFqn, violations, context);
validatePolymorphicEventForm(polyPath, polyEvent, violations); validatePolymorphicEventForm(polyPath, polyEvent, violations);
} }
} }
validateMatchedTransitionsWhenResolvable( validateMatchedTransitionsWhenResolvable(
prefix, chain, trigger, transitions, machineTypes.eventTypeFqn(), violations); prefix, chain, trigger, transitions, machineTypes.eventTypeFqn(), violations, context);
validateOverLinkedPolymorphicEvents( validateOverLinkedPolymorphicEvents(
prefix, chain, trigger, transitions, machineTypes.eventTypeFqn(), violations); prefix, chain, trigger, transitions, machineTypes.eventTypeFqn(), violations, context);
} }
if (chain.getMatchedTransitions() != null) { if (chain.getMatchedTransitions() != null) {
for (int j = 0; j < chain.getMatchedTransitions().size(); j++) { for (int j = 0; j < chain.getMatchedTransitions().size(); j++) {
MatchedTransition matched = chain.getMatchedTransitions().get(j); MatchedTransition matched = chain.getMatchedTransitions().get(j);
String matchedPrefix = prefix + ".matchedTransitions[" + j + "]"; String matchedPrefix = prefix + ".matchedTransitions[" + j + "]";
requireCanonical(matchedPrefix + ".event", matched.getEvent(), machineTypes.eventTypeFqn(), violations); requireCanonical(matchedPrefix + ".event", matched.getEvent(), machineTypes.eventTypeFqn(), violations, context);
requireCanonical(matchedPrefix + ".sourceState", matched.getSourceState(), machineTypes.stateTypeFqn(), violations); requireCanonical(matchedPrefix + ".sourceState", matched.getSourceState(), machineTypes.stateTypeFqn(), violations, context);
requireCanonical(matchedPrefix + ".targetState", matched.getTargetState(), machineTypes.stateTypeFqn(), violations); requireCanonical(matchedPrefix + ".targetState", matched.getTargetState(), machineTypes.stateTypeFqn(), violations, context);
} }
} }
} }
@@ -292,11 +300,12 @@ public final class AnalysisCanonicalFormValidator {
String path, String path,
String value, String value,
String enumTypeFqn, String enumTypeFqn,
List<Violation> violations) { List<Violation> violations,
if (!MachineEnumCanonicalizer.isMachineEnumReference(value, enumTypeFqn)) { CodebaseContext context) {
if (!MachineEnumCanonicalizer.isMachineEnumReference(value, enumTypeFqn, context)) {
return; return;
} }
String expected = MachineEnumCanonicalizer.canonicalizeLabel(value, enumTypeFqn); String expected = MachineEnumCanonicalizer.canonicalizeLabel(value, enumTypeFqn, context);
if (!expected.equals(value)) { if (!expected.equals(value)) {
violations.add(new Violation(path, value, expected)); violations.add(new Violation(path, value, expected));
} }
@@ -347,7 +356,8 @@ public final class AnalysisCanonicalFormValidator {
TriggerPoint trigger, TriggerPoint trigger,
List<Transition> transitions, List<Transition> transitions,
String eventTypeFqn, String eventTypeFqn,
List<Violation> violations) { List<Violation> violations,
CodebaseContext context) {
if (trigger.getPolymorphicEvents() == null || trigger.getPolymorphicEvents().isEmpty()) { if (trigger.getPolymorphicEvents() == null || trigger.getPolymorphicEvents().isEmpty()) {
return; return;
} }
@@ -373,7 +383,7 @@ public final class AnalysisCanonicalFormValidator {
return; return;
} }
int matchingTransitionCount = countMatchingConfiguredTransitions( int matchingTransitionCount = countMatchingConfiguredTransitions(
transitions, trigger.getPolymorphicEvents(), eventTypeFqn); transitions, trigger.getPolymorphicEvents(), eventTypeFqn, context);
if (matchingTransitionCount > 0 if (matchingTransitionCount > 0
&& chain.getMatchedTransitions() != null && chain.getMatchedTransitions() != null
&& chain.getMatchedTransitions().size() > matchingTransitionCount) { && chain.getMatchedTransitions().size() > matchingTransitionCount) {
@@ -387,7 +397,8 @@ public final class AnalysisCanonicalFormValidator {
private static int countMatchingConfiguredTransitions( private static int countMatchingConfiguredTransitions(
List<Transition> transitions, List<Transition> transitions,
List<String> polymorphicEvents, List<String> polymorphicEvents,
String eventTypeFqn) { String eventTypeFqn,
CodebaseContext context) {
if (transitions == null || transitions.isEmpty() || polymorphicEvents == null) { if (transitions == null || transitions.isEmpty() || polymorphicEvents == null) {
return 0; return 0;
} }
@@ -400,7 +411,7 @@ public final class AnalysisCanonicalFormValidator {
? transition.getEvent().fullIdentifier() ? transition.getEvent().fullIdentifier()
: transition.getEvent().rawName(); : transition.getEvent().rawName();
for (String polyEvent : polymorphicEvents) { for (String polyEvent : polymorphicEvents) {
if (eventsMatch(polyEvent, smEvent, eventTypeFqn)) { if (eventsMatch(polyEvent, smEvent, eventTypeFqn, context)) {
count++; count++;
break; break;
} }
@@ -415,7 +426,8 @@ public final class AnalysisCanonicalFormValidator {
TriggerPoint trigger, TriggerPoint trigger,
List<Transition> transitions, List<Transition> transitions,
String eventTypeFqn, String eventTypeFqn,
List<Violation> violations) { List<Violation> violations,
CodebaseContext context) {
if (LifecycleTriggerMarkers.isLifecycle(trigger.getEvent())) { if (LifecycleTriggerMarkers.isLifecycle(trigger.getEvent())) {
return; return;
} }
@@ -429,7 +441,7 @@ public final class AnalysisCanonicalFormValidator {
MachineEnumCanonicalizer.TriggerEventKind eventKind = MachineEnumCanonicalizer.TriggerEventKind eventKind =
MachineEnumCanonicalizer.classifyTriggerEvent(trigger.getEvent()); MachineEnumCanonicalizer.classifyTriggerEvent(trigger.getEvent());
if (eventKind == MachineEnumCanonicalizer.TriggerEventKind.CANONICAL_ENUM) { if (eventKind == MachineEnumCanonicalizer.TriggerEventKind.CANONICAL_ENUM) {
if (eventMatchesAnyTransition(trigger.getEvent(), transitions, eventTypeFqn)) { if (eventMatchesAnyTransition(trigger.getEvent(), transitions, eventTypeFqn, context)) {
violations.add(new Violation( violations.add(new Violation(
chainPrefix + ".matchedTransitions", chainPrefix + ".matchedTransitions",
"empty", "empty",
@@ -444,7 +456,7 @@ public final class AnalysisCanonicalFormValidator {
if (eventKind != MachineEnumCanonicalizer.TriggerEventKind.DYNAMIC_EXPRESSION) { if (eventKind != MachineEnumCanonicalizer.TriggerEventKind.DYNAMIC_EXPRESSION) {
return; return;
} }
if (!polymorphicEventsMatchAnyTransition(trigger.getPolymorphicEvents(), transitions, eventTypeFqn)) { if (!polymorphicEventsMatchAnyTransition(trigger.getPolymorphicEvents(), transitions, eventTypeFqn, context)) {
return; return;
} }
violations.add(new Violation( violations.add(new Violation(
@@ -476,7 +488,8 @@ public final class AnalysisCanonicalFormValidator {
private static boolean eventMatchesAnyTransition( private static boolean eventMatchesAnyTransition(
String triggerEvent, String triggerEvent,
List<Transition> transitions, List<Transition> transitions,
String eventTypeFqn) { String eventTypeFqn,
CodebaseContext context) {
if (transitions == null || transitions.isEmpty() || triggerEvent == null) { if (transitions == null || transitions.isEmpty() || triggerEvent == null) {
return false; return false;
} }
@@ -487,7 +500,7 @@ public final class AnalysisCanonicalFormValidator {
String smEvent = transition.getEvent().fullIdentifier() != null String smEvent = transition.getEvent().fullIdentifier() != null
? transition.getEvent().fullIdentifier() ? transition.getEvent().fullIdentifier()
: transition.getEvent().rawName(); : transition.getEvent().rawName();
if (eventsMatch(triggerEvent, smEvent, eventTypeFqn)) { if (eventsMatch(triggerEvent, smEvent, eventTypeFqn, context)) {
return true; return true;
} }
} }
@@ -497,7 +510,8 @@ public final class AnalysisCanonicalFormValidator {
private static boolean polymorphicEventsMatchAnyTransition( private static boolean polymorphicEventsMatchAnyTransition(
List<String> polymorphicEvents, List<String> polymorphicEvents,
List<Transition> transitions, List<Transition> transitions,
String eventTypeFqn) { String eventTypeFqn,
CodebaseContext context) {
if (transitions == null || transitions.isEmpty()) { if (transitions == null || transitions.isEmpty()) {
return false; return false;
} }
@@ -509,7 +523,7 @@ public final class AnalysisCanonicalFormValidator {
String smEvent = transition.getEvent().fullIdentifier() != null String smEvent = transition.getEvent().fullIdentifier() != null
? transition.getEvent().fullIdentifier() ? transition.getEvent().fullIdentifier()
: transition.getEvent().rawName(); : transition.getEvent().rawName();
if (eventsMatch(polyEvent, smEvent, eventTypeFqn)) { if (eventsMatch(polyEvent, smEvent, eventTypeFqn, context)) {
return true; return true;
} }
} }
@@ -517,7 +531,11 @@ public final class AnalysisCanonicalFormValidator {
return false; return false;
} }
private static boolean eventsMatch(String triggerEvent, String smEvent, String eventTypeFqn) { private static boolean eventsMatch(
String triggerEvent,
String smEvent,
String eventTypeFqn,
CodebaseContext context) {
if (triggerEvent == null || smEvent == null) { if (triggerEvent == null || smEvent == null) {
return false; return false;
} }
@@ -529,8 +547,8 @@ public final class AnalysisCanonicalFormValidator {
if (!triggerConst.equals(smConst)) { if (!triggerConst.equals(smConst)) {
return false; return false;
} }
if (MachineEnumCanonicalizer.isMachineEnumReference(triggerEvent, eventTypeFqn) if (MachineEnumCanonicalizer.isMachineEnumReference(triggerEvent, eventTypeFqn, context)
&& MachineEnumCanonicalizer.isMachineEnumReference(smEvent, eventTypeFqn)) { && MachineEnumCanonicalizer.isMachineEnumReference(smEvent, eventTypeFqn, context)) {
return true; return true;
} }
return false; return false;

View File

@@ -392,6 +392,47 @@ class AnalysisCanonicalFormValidatorTest {
.noneMatch(v -> v.path().contains("matchedTransitions")); .noneMatch(v -> v.path().contains("matchedTransitions"));
} }
@Test
void shouldSkipImportStyleCanonicalCheckWhenSimpleNameIsAmbiguous(@TempDir Path tempDir) throws IOException {
Files.createDirectories(tempDir.resolve("a"));
Files.createDirectories(tempDir.resolve("b"));
Files.createDirectories(tempDir.resolve("com/example/config"));
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 }");
Files.writeString(tempDir.resolve("a/OrderState.java"), "package a; public enum OrderState { NEW, PAID }");
Files.writeString(tempDir.resolve("com/example/config/OrderStateMachineConfiguration.java"),
"""
package com.example.config;
import a.OrderEvent;
import a.OrderState;
import org.springframework.statemachine.config.EnumStateMachineConfigurerAdapter;
public class OrderStateMachineConfiguration
extends EnumStateMachineConfigurerAdapter<OrderState, OrderEvent> {
}
""");
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
AnalysisResult result = AnalysisResult.builder()
.name("com.example.config.OrderStateMachineConfiguration")
.transitions(List.of(transition(
"a.OrderEvent.PAY",
"a.OrderState.NEW",
"a.OrderState.PAID")))
.metadata(CodebaseMetadata.builder()
.triggers(List.of(TriggerPoint.builder()
.event("OrderEvent.PAY")
.className("com.example.web.Controller")
.methodName("pay")
.sourceFile("Controller.java")
.build()))
.build())
.build();
assertThat(AnalysisCanonicalFormValidator.validate(result, context)).isEmpty();
}
@Test @Test
void shouldSkipValidationForStringStateMachines(@TempDir Path tempDir) throws IOException { void shouldSkipValidationForStringStateMachines(@TempDir Path tempDir) throws IOException {
Path configPkg = tempDir.resolve("com/example/config"); Path configPkg = tempDir.resolve("com/example/config");